Compare commits

...
9 Commits
Author SHA1 Message Date
ethernet 20617bc18a fix: prefer managed uv in MCP stdio command resolver
After #37660, Hermes owns its own uv/uvx at $HERMES_HOME/bin/. The
MCP resolver's candidate list for bare uv/uvx commands must check this
location before falling back to ~/.local/bin, /opt/homebrew/bin, etc.,
so MCP servers use the same managed uv as the CLI update path.

Adds test_resolve_stdio_command_prefers_managed_uv to verify ordering
against a stale ~/.local/bin/uv.
2026-06-02 18:47:06 -04:00
mohamedorigami-jpg f4ce36cd47 fix(tools): resolve uv/uvx MCP commands under GUI-style PATHs (fixes #37589)
_tools/mcp_tool._resolve_stdio_command_ already fell back to ~/.local/bin
and /usr/local/bin for bare npx/npm/node MCP commands on
filtered PATHs (the docker sandbox case), but it did NOT cover uv
and uvx — the dominant Python MCP server runtime. On macOS,
Hermes Desktop inherits a minimal LaunchAgent PATH
(/usr/bin:/bin:/usr/sbin:/sbin) that omits ~/.local/bin (the
uv user installer target), /opt/homebrew/bin (Apple Silicon
Homebrew), and /usr/local/bin (Intel Homebrew / Linux from-source).
A bare command: uvx MCP server therefore fails with ENOENT at
execvp from Hermes Desktop even though it works from an interactive
terminal.

This adds uv and uvx to the candidate allowlist, plus a new
/opt/homebrew/bin candidate for the Apple Silicon case. Existing
ordering is preserved (~/.local/bin before /opt/homebrew/bin before
/usr/local/bin), so users with multiple installs continue to resolve
to whichever the user installed first.

Tests: 4 new tests in test_mcp_tool_issue_948.py cover the ~/.local/bin
fallback, shutil.which preemption, an unknown-command negative case
(catches the inverse regression of someone adding a too-broad allowlist
that rewrites bare my-tool to a coincidentally-named file), and
the existing npx tests continue to pass.

Closes #37589
2026-06-03 00:43:37 +03:00
ethernet c47b9d126f Merge pull request #37597 from NousResearch/ethie/desktop-linux-install
feat(desktop): content-hash build stamp, --build-only / --force-build flags
2026-06-02 16:51:44 -04:00
Austin PickettandCursor ac76bbe21f fix(desktop): triage batch of GUI quality-of-life fixes (#37536)
* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing

A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.

Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
  claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
  Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
  paint a horizontal scrollbar at the bottom of the window.

Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
  reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
  @icons-pack/react-simple-icons (telegram, discord, matrix, signal,
  whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
  Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
  owner request).
- Drop the duplicate "Create first cron" button in the empty state.

Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
  Blob identity; Chromium hands us the same screenshot via both
  clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
  spellchecker with the system locale on whenReady, and add
  replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
  backtick code + fenced ``` blocks) while keeping @file:/@image:
  directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
  submenu.
- Bake cursor-pointer into the <Button> primitive (with
  disabled:cursor-default) and into titlebarButtonClass.

Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
  bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
  onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
  every update check, and on throttled window focus so About reflects
  the just-installed binary.

Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
  groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
  showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
  terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
  the two streams as separate labeled blocks with stderr in a neutral
  tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.

Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
  user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
  preload bridge + global.d.ts typing + a "Default project directory"
  row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
  base; ShellFileOperations.delete_file rewritten to run a cross-
  platform python3 -c snippet so deletes work on Windows shells (which
  have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
  PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
  and theme-color meta.

Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
  8-minute silence on the stream auto-clears stuck $workingSessionIds
  entries so "Session Busy" never gets permanently wedged. Wired into
  useSessionStateCache so every state update refreshes the timer.

i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
  (recommends react-intl, audits IME/RTL/CJK in the composer +
  chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
  non-English locale).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): replace native OS scrollbar in portaled dropdown menus

Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.

Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle

Two regressions from the previous dropdown-scrollbar fix:

- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
  variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
  cn() call were being mis-resolved so the `rounded-full` leaked onto the
  menu container itself. Replaced the whole tower of arbitrary variants
  with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
  `.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
  parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
  --radix-dropdown-menu-content-available-height on Content but NOT on
  SubContent, so the `max-h` bound to that variable computed to 0 and the
  submenu collapsed to zero height. Switched SubContent to a fixed
  max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog

The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.

Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
  another round of Radix positioning bugs.

Also extract types/interfaces to the bottom of the file per workspace
convention.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): move cron 'New cron' button off the top bar into the body

Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.

- Empty (zero jobs): EmptyState renders the "Create first cron" button
  again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
  search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
  a single "New cron" button (right-aligned). The rows themselves
  already cover edit/pause/trigger/delete, so this is the only "create"
  affordance.

Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop): address Copilot review on PR 37536

- sessions-settings: guard the WHOLE bridge call rather than chaining
  `?.settings.foo().then(...)` — the latter throws when
  `window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
  because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
  generated delete snippet still works on remote backends running
  Python 3.7. The existing FileNotFoundError handler covers the same
  case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
  (basic/bright colors, bold toggles, default-fg reset, coalescing,
  256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
  full-reset) so future refactors can't silently regress terminal
  rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(desktop/updates): swallow refreshDesktopVersion bridge errors

`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(desktop): drop work duplicated by other in-flight PRs

- composer/text-utils.ts: revert paste-image dedupe — PR #37596
  ships the same fix with a cleaner content-key approach and a
  Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
  has already shipped a working i18n surface (homegrown nanostores
  `t()` helper over en/zh dictionaries), so the RFC's framework
  recommendation (`react-intl`) is now obsolete and would just
  contradict the implementation that's actually landing.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 16:33:22 -04:00
brooklyn! 31c40c72c0 fix(desktop): stabilize project folder sessions (#37586)
* fix(desktop): stabilize project folder sessions

Keep desktop folder selection aligned with new sessions and scope TUI gateway cwd through session context so prompts and tools resolve against the selected workspace.

* fix(desktop): address review feedback on folder sessions

Snapshot sessions before iterating to avoid concurrent-mutation crashes,
optional-chain the revealLogs catch, and read console-message args from
the correct Electron event/messageDetails positions.

* fix(desktop): address second review pass on folder sessions

Sync the remembered workspace key with the cwd atom (clear on empty),
only load tree children for real directory nodes, and throttle renderer
auto-reloads so a deterministic startup crash can't loop forever.

* fix(desktop): inherit parent workspace for ephemeral agent tasks

Background and preview tasks use ephemeral ids absent from the session
map, so pass the parent session cwd into the session context explicitly
instead of clearing it back to the gateway launch dir. Also correct the
set_session_vars docstring about clear_session_vars semantics.

* fix(desktop): validate preview cwd before pinning session context

A non-empty but non-existent client cwd would pin an unusable override
and silently fall back to the launch dir. Validate once, reuse for both
the session context and the terminal override, and fall back to the
parent session workspace when invalid.

* fix(desktop): harden preview cwd normalization and adopt normalized cwd

Guard preview cwd normalization against malformed client paths so a bad
input can't fail the whole restart, and adopt the backend's normalized
config.get cwd in the no-active-session path so the persisted workspace
stays consistent with what the agent uses.
2026-06-02 20:23:09 +00:00
Teknium 79bfddd37c fix(models): restore gemini-3-flash-preview to Gemini OAuth picker (#37606)
#37046 swapped gemini-3-flash-preview -> gemini-3.5-flash in the
google-gemini-cli (OAuth/Code Assist) picker on the premise that the
preview slug was renamed. It wasn't. Per gemini-cli's models.ts, Code
Assist serves two distinct flash slugs with different access gates:
gemini-3-flash-preview (PREVIEW_GEMINI_FLASH_MODEL — what subscription/
free-tier OAuth users reach) and gemini-3.5-flash
(DEFAULT_GEMINI_3_5_FLASH_MODEL — GA-channel-gated). The model string is
passed verbatim into the {project, model, ...} envelope sent to
cloudcode-pa.googleapis.com, so non-GA users got a hard error on every
prompt because gemini-3.5-flash 404s for them.

Offer both slugs in the OAuth picker (matching gemini-cli's own /model
list) so non-GA users can select the preview flash that works. The
gemini (API-key), OpenRouter, and Nous lists are untouched —
google/gemini-3.5-flash is a real live model on those surfaces.
2026-06-02 12:49:19 -07:00
ethernet c2050183a5 feat(desktop): content-hash build stamp with --build-only and --force-build flags
Add a SHA-256 content-hash based build stamp to `hermes desktop` so
unchanged source trees skip the npm install + build step. Uses pathspec
for .gitignore-aware file matching instead of a hardcoded skip-list.

New CLI flags:
- --build-only: run the build but don't launch the app
- --force-build: rebuild even when the stamp matches

`hermes update` now calls `hermes desktop --build-only` so the
desktop app is rebuilt (if needed) as part of the update flow.

16/16 tests passing.
2026-06-02 15:45:30 -04:00
brooklyn!andCopilot Autofix powered by AI b34ee80741 feat(installer): rename macOS installer to "Hermes" and make it a launcher (#37516)
* feat(installer): rename macOS installer to "Hermes" and make it a launcher

The bootstrap installer was branded "Hermes Setup" and always re-ran the full
install flow on every open — so the /Applications app said "Setup" and couldn't
double as a way to relaunch Hermes (the real desktop app lives in ~/.hermes,
not /Applications, with no Dock/Launchpad entry).

Two changes, macOS-focused:

1. Rename the installer's user-visible name to "Hermes" (productName, window
   title, shortDescription, document title). Bundle id stays
   com.nousresearch.hermes.setup (distinct from the desktop app's
   com.nousresearch.hermes); the on-disk staged updater name (hermes-setup) is
   unchanged, so the desktop's update hand-off still resolves it.

2. Launcher fast path: on a bare ("Install") launch, if Hermes is already
   installed (bootstrap-complete marker + a built desktop app on disk), skip the
   installer UI entirely and relaunch the desktop app, then exit. First run still
   installs; Update mode and fresh/repair installs still show the UI. The window
   now starts hidden ("visible": false) and is revealed only when the UI is
   actually needed, so the launcher path never flashes a window.

Net UX: one "Hermes" in /Applications you can pin to the Dock — first click
installs, every later click opens the app instantly (same icon throughout, so
the Dock stays seamless). Nothing pins to the Dock permanently; the app shows a
normal Dock icon only while running.

Windows naming is intentionally left as-is in this change (scope: macOS).

* fix(installer): gate launcher fast path to macOS + log window-show failures

Address review feedback:
- Gate the already-installed launcher fast path to macOS (cfg!(target_os =
  "macos")). On Windows/Linux the installer keeps its prior behavior, so the
  change is a pure no-op there. This avoids relaunching the desktop app on
  Windows via a spawn that lacks the DETACHED_PROCESS + startup-grace handling
  launch_hermes_desktop uses (which could race the installer's exit).
- Add a brief startup grace before exiting on the mac fast path, mirroring
  launch_hermes_desktop.
- Log (instead of silently ignoring) failures to show the main window, and log
  when the "main" window can't be found, so a no-UI state is diagnosable.

* fix(installer): add --reinstall escape hatch + keep spawn detached on Windows

Address follow-up review:
- Add a `--reinstall`/`--repair` flag that forces the installer UI even when
  Hermes is already installed, so a broken install can be repaired by re-running
  setup instead of the launcher fast path silently relaunching the (possibly
  bad) app.
- Apply DETACHED_PROCESS on Windows in spawn_installed_desktop, mirroring
  launch_hermes_desktop, so the helper stays correct cross-platform even though
  its only caller is macOS-gated today.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* test(installer): unit-test --reinstall/--repair force-setup parsing

Extract the force-setup flag parsing into a unit-testable
`force_setup_from_args` helper (mirrors `AppMode::from_args`) and add tests:
- --reinstall and --repair are recognized
- bare/unrelated args (incl. --update) do not force setup
- the repair flags never affect Install<->Update mode selection

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 17:47:34 +00:00
brooklyn!andteknium1 bb0619dbce fix(auth): align Codex OAuth persistence paths (#37517)
* fix(desktop): codex OAuth onboarding now resolves on fresh install

The desktop codex device-code worker persisted tokens with a hand-rolled
pool.add_entry(), writing only credential_pool.openai-codex. It never set
active_provider, so on a fresh install the onboarding setup.runtime_check
resolved provider "auto", couldn't detect the Codex OAuth session, and raised
"No inference provider configured" — while setup.status (which sniffs the pool)
reported configured. The disagreement surfaced as the onboarding banner
"Connected, but Hermes still cannot resolve a usable provider."

Use the canonical _save_codex_tokens() instead, matching the CLI's
`hermes auth add openai-codex` path and the Nous/MiniMax dashboard workers.
It writes the providers.openai-codex singleton (setting active_provider) and
syncs the pool.

* fix(auth): align Codex OAuth persistence paths

Ensure desktop and CLI Codex OAuth logins both write the canonical provider state so fresh installs resolve a usable runtime provider.

---------

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-06-02 12:19:44 -05:00
67 changed files with 2828 additions and 2346 deletions
+2 -1
View File
@@ -1891,6 +1891,7 @@ 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,
@@ -1902,7 +1903,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": label_from_token(tokens.get("access_token", ""), "device_code"),
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
},
)
+34 -5
View File
@@ -6,16 +6,42 @@ 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.
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.
Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
"""
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()
@@ -27,7 +53,10 @@ 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).
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
# 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()
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 Setup</title>
<title>Hermes</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>).
fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
pub(crate) 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,6 +232,35 @@ fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf>
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
// ---------------------------------------------------------------------------
+100 -2
View File
@@ -50,6 +50,20 @@ 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
@@ -85,7 +99,11 @@ pub fn run() {
let _guard = paths::init_logging();
let mode = AppMode::from_args(std::env::args().skip(1));
tracing::info!(?mode, "Hermes Setup starting");
// 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");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -93,6 +111,60 @@ 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,
@@ -115,7 +187,7 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::AppMode;
use super::{force_setup_from_args, AppMode};
#[test]
fn bare_args_are_install() {
@@ -131,4 +203,30 @@ 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 Setup",
"productName": "Hermes",
"version": "0.0.1",
"identifier": "com.nousresearch.hermes.setup",
"build": {
@@ -13,7 +13,7 @@
"windows": [
{
"label": "main",
"title": "Hermes Setup",
"title": "Hermes",
"width": 880,
"height": 620,
"minWidth": 720,
@@ -22,7 +22,8 @@
"fullscreen": false,
"decorations": true,
"transparent": false,
"center": true
"center": true,
"visible": false
}
],
"security": {
@@ -33,7 +34,7 @@
"bundle": {
"active": true,
"category": "DeveloperTool",
"shortDescription": "Hermes Setup",
"shortDescription": "Hermes",
"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",
+16 -3
View File
@@ -111,15 +111,28 @@ 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" # macOS/Linux
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
# Rebuild a broken Python venv
rm -rf "$HOME/.hermes/hermes-agent/venv" # macOS/Linux
# Reset a stuck macOS microphone prompt
rm -rf "$HOME/.hermes/hermes-agent/venv"
# Reset a stuck macOS microphone prompt (macOS only)
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
+232 -4
View File
@@ -429,6 +429,13 @@ 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
@@ -528,6 +535,39 @@ 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
}
@@ -1519,10 +1559,18 @@ 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,
process.cwd(),
IS_PACKAGED ? null : process.cwd(),
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
app.getPath('home')
]
@@ -1536,6 +1584,48 @@ 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
@@ -2695,6 +2785,28 @@ 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) {
@@ -3222,6 +3334,51 @@ 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 {
@@ -3372,13 +3529,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
})
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
const properties = ['openFile']
if (options?.directories) properties.push('openDirectory')
const properties = options?.directories ? ['openDirectory'] : ['openFile']
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: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
defaultPath: resolvedDefaultPath,
properties,
filters: Array.isArray(options?.filters) ? options.filters : undefined
})
@@ -3437,6 +3602,45 @@ 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 () => {
@@ -3746,6 +3950,7 @@ app.whenReady().then(() => {
installMediaPermissions()
registerMediaProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
createWindow()
app.on('activate', () => {
@@ -3753,6 +3958,29 @@ 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,6 +31,11 @@ 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),
+5 -2
View File
@@ -3,8 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<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" />
<title>Hermes</title>
</head>
<body>
+1
View File
@@ -50,6 +50,7 @@
"@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",
@@ -1,14 +1,20 @@
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'
@@ -17,6 +23,24 @@ 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,
@@ -25,81 +49,114 @@ export function ContextMenu({
onPickFiles,
onPickFolders,
onPickImages
}: {
state: ChatBarState
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
}) {
}: 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)
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 />
<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>
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
Prompt snippets
</ContextMenuItem>
<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>
<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>
)
}
@@ -108,12 +165,7 @@ export function ContextMenuItem({
disabled,
icon: Icon,
onSelect
}: {
children: string
disabled?: boolean
icon: IconComponent
onSelect?: () => void
}) {
}: ContextMenuItemProps) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />
@@ -121,3 +173,33 @@ 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[]
}
@@ -1024,6 +1024,8 @@ 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',
@@ -1045,6 +1047,7 @@ export function ChatBar({
onPaste={handlePaste}
ref={editorRef}
role="textbox"
spellCheck="true"
suppressContentEditableWarning
/>
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
@@ -97,6 +97,17 @@ 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)" />
+30 -19
View File
@@ -67,6 +67,12 @@ 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',
@@ -438,7 +444,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={['⇧', 'N']} />
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
)}
</>
)}
@@ -540,23 +546,28 @@ export function ChatSidebar({
forceEmptyState={showSessionSkeletons}
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
<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>
// 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
}
label="Sessions"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
@@ -633,7 +644,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 to pin a chat</span>
<span>Shift-click a chat to pin · drag to reorder</span>
</div>
)
}
+17 -10
View File
@@ -428,14 +428,6 @@ 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={
@@ -457,6 +449,10 @@ 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={
@@ -469,6 +465,19 @@ 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
@@ -484,8 +493,6 @@ 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}>
+12 -4
View File
@@ -372,14 +372,22 @@ export function DesktopController() {
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
return
}
if (event.shiftKey && event.code === 'KeyN') {
event.preventDefault()
startFreshSessionDraft()
// 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
}
event.preventDefault()
startFreshSessionDraft()
}
window.addEventListener('keydown', onKeyDown)
+2 -36
View File
@@ -21,6 +21,8 @@ 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
}
@@ -39,29 +41,6 @@ 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',
@@ -442,19 +421,6 @@ 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,
@@ -0,0 +1,97 @@
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>
)
}
+9 -3
View File
@@ -28,10 +28,16 @@ export function PageSearchShell({
{...props}
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
{/*
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]">
{/* Reserve the top-right titlebar tools + native window-controls
footprint so the full-width search input never slides under them
(this header sits in the titlebar row at the window top). */}
footprint so the full-width search input never slides under them. */}
<div
style={{
paddingRight:
@@ -11,6 +11,8 @@ const ROW_HEIGHT = 22
const INDENT = 10
interface ProjectTreeProps {
collapseNonce: number
cwd: string
data: TreeNode[]
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
@@ -21,6 +23,8 @@ interface ProjectTreeProps {
}
export function ProjectTree({
collapseNonce,
cwd,
data,
onActivateFile,
onActivateFolder,
@@ -63,7 +67,7 @@ export function ProjectTree({
onNodeOpenChange(id, node.isOpen)
if (node.isOpen && node.data.children === undefined) {
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
void onLoadChildren(id)
}
},
@@ -72,7 +76,7 @@ export function ProjectTree({
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (!node.data.isDirectory) {
if (node.data && !node.data.isDirectory) {
onPreviewFile?.(node.data.id)
}
},
@@ -83,7 +87,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
@@ -91,6 +95,7 @@ export function ProjectTree({
height={size.height}
indent={INDENT}
initialOpenState={openState}
key={`${cwd}:${collapseNonce}`}
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
@@ -135,6 +140,10 @@ 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,16 +47,20 @@ 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
@@ -67,6 +71,7 @@ interface ProjectTreeState {
}
const initialState: ProjectTreeState = {
collapseNonce: 0,
cwd: '',
data: [],
loaded: false,
@@ -112,6 +117,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
$projectTree.set({
collapseNonce: current.collapseNonce,
cwd,
data: [],
loaded: false,
@@ -174,6 +180,19 @@ 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)) {
@@ -222,6 +241,8 @@ 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 : {},
@@ -231,10 +252,12 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
setNodeOpen
}),
[
collapseAll,
cwd,
loadChildren,
refreshRoot,
setNodeOpen,
state.collapseNonce,
state.cwd,
state.data,
state.openState,
+72 -12
View File
@@ -1,6 +1,7 @@
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'
@@ -52,7 +53,10 @@ export function RightSidebarPane({
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
@@ -97,6 +101,8 @@ export function RightSidebarPane({
<TerminalSlot />
) : (
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
@@ -106,6 +112,7 @@ export function RightSidebarPane({
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
@@ -160,13 +167,22 @@ 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,
@@ -176,6 +192,7 @@ function FilesystemTab({
onActivateFile,
onActivateFolder,
onChangeFolder,
onCollapseAll,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
@@ -188,14 +205,35 @@ 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 : 'No folder selected'}
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
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="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"
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
@@ -206,6 +244,7 @@ function FilesystemTab({
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
cwd={cwd}
data={data}
error={error}
@@ -226,6 +265,7 @@ export function RightSidebarSectionHeader({ children }: { children: ReactNode })
}
interface FileTreeBodyProps {
collapseNonce: number
cwd: string
data: ReturnType<typeof useProjectTree>['data']
error: string | null
@@ -239,6 +279,7 @@ interface FileTreeBodyProps {
}
function FileTreeBody({
collapseNonce,
cwd,
data,
error,
@@ -267,15 +308,34 @@ function FileTreeBody({
}
return (
<ProjectTree
data={data}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
<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>
)
}
@@ -50,16 +50,23 @@ export function useCwdActions({
}
if (!activeSessionId) {
setCurrentCwd(trimmed)
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: trimmed
})
setCurrentCwd(info.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)
}
setCurrentBranch(info.branch || '')
} catch (err) {
notifyError(err, 'Working directory change failed')
} catch {
setCurrentBranch('')
}
return
@@ -15,6 +15,7 @@ import {
$currentCwd,
$messages,
$sessions,
getRememberedWorkspaceCwd,
setActiveSessionId,
setAwaitingResponse,
setBusy,
@@ -32,6 +33,7 @@ import {
setMessages,
setSelectedStoredSessionId,
setSessions,
setSessionsTotal,
setSessionStartedAt,
setTurnStartedAt
} from '@/store/session'
@@ -291,7 +293,8 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
setCurrentCwd('')
// New chats inherit the current workspace.
setCurrentCwd(getRememberedWorkspaceCwd())
setCurrentBranch('')
clearComposerDraft()
clearComposerAttachments()
@@ -308,7 +311,7 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
const cwd = $currentCwd.get().trim()
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
@@ -687,6 +690,9 @@ 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
@@ -709,6 +715,7 @@ export function useSessionActions({
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
setSessionsTotal(prev => prev + 1)
}
$pinnedSessionIds.set(previousPinned)
@@ -761,6 +768,10 @@ 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) {
@@ -773,6 +784,7 @@ 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, setSessionWorking } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -140,6 +140,13 @@ 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 { useState } from 'react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
@@ -10,7 +10,8 @@ import {
$updateChecking,
$updateStatus,
checkUpdates,
openUpdatesWindow
openUpdatesWindow,
refreshDesktopVersion
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
@@ -46,6 +47,14 @@ 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'
+24 -46
View File
@@ -22,8 +22,6 @@ 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
@@ -186,8 +184,11 @@ 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">
@@ -222,27 +223,17 @@ export function KeysSettings({ query }: SearchProps) {
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
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
}
})
// 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.
useEffect(() => {
try {
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore persistence failures and keep in-memory preference.
// Ignore — old key cleanup is best-effort.
}
}, [showAdvanced])
}, [])
useEffect(() => {
let cancelled = false
@@ -262,28 +253,21 @@ 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 (!showAdvanced && Boolean(info.advanced)) {
return false
}
if (!q) {
return true
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
},
[showAdvanced]
)
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}, [])
const providerGroups = useMemo<ProviderGroup[]>(() => {
if (!vars) {
@@ -415,12 +399,6 @@ 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, Loader2, Trash2 } from '@/lib/icons'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
@@ -105,6 +105,8 @@ export function SessionsSettings({ query }: SearchProps) {
return (
<SettingsContent>
<DefaultProjectDirSetting />
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
@@ -166,3 +168,104 @@ 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>
)
}
@@ -54,14 +54,18 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
)}
{...props}
>
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
{/* `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">
{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-auto">
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
{items
.filter(item => !item.hidden)
.map(item => (
+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)] 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)] cursor-pointer 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))]'
@@ -0,0 +1,34 @@
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,7 +48,8 @@ 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, DirectiveText } from '@/components/assistant-ui/directive-text'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { UserMessageText } from '@/components/assistant-ui/user-message-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'
@@ -703,9 +704,10 @@ const UserMessage: FC<{
</span>
)}
{hasBody && (
<span className="wrap-anywhere block whitespace-pre-line">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</span>
// 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.
<UserMessageText className="wrap-anywhere" text={messageText} />
)}
</>
)
@@ -35,7 +35,18 @@ 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
@@ -1002,6 +1013,10 @@ 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)
@@ -1209,6 +1224,18 @@ 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,
@@ -1220,7 +1247,10 @@ 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,6 +5,7 @@ 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'
@@ -344,11 +345,41 @@ 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.detail}</pre>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
</pre>
) : (
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
)}
@@ -0,0 +1,150 @@
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>
)
}
@@ -0,0 +1,91 @@
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 -1
View File
@@ -5,7 +5,7 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"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",
"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",
{
variants: {
variant: {
+4 -1
View File
@@ -46,7 +46,10 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'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',
// 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',
className
)}
data-slot="dialog-content"
@@ -24,8 +24,11 @@ function DropdownMenuContent({
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(
'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',
'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',
className
)}
data-slot="dropdown-menu-content"
@@ -188,8 +191,13 @@ function DropdownMenuSubContent({
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
// SubContent inherits the same portal/scrollbar issue as Content (Radix
// renders it under document.body), so apply `dt-portal-scrollbar`. 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(
'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',
'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
)}
data-slot="dropdown-menu-sub-content"
+5
View File
@@ -27,6 +27,11 @@ 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
@@ -0,0 +1,123 @@
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
@@ -0,0 +1,175 @@
// 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)
}
+5 -1
View File
@@ -20,7 +20,11 @@ const PRIORITY_KEYS = [
] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] 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 NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
type Json = Record<string, unknown>
+12 -9
View File
@@ -6,6 +6,7 @@ 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'
@@ -32,14 +33,16 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
<ErrorBoundary label="root">
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
)
+70 -2
View File
@@ -3,10 +3,15 @@ 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
@@ -39,7 +44,7 @@ export const $currentProvider = atom('')
export const $currentReasoningEffort = atom('')
export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false)
export const $currentCwd = atom('')
export const $currentCwd = atom(getRememberedWorkspaceCwd())
export const $currentBranch = atom('')
export const $currentUsage = atom<UsageStats>({
calls: 0,
@@ -73,7 +78,14 @@ 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)
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 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)
@@ -85,6 +97,53 @@ 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
@@ -99,4 +158,13 @@ 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)
}
}
+37 -1
View File
@@ -145,6 +145,34 @@ 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
@@ -158,6 +186,10 @@ 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) {
@@ -249,7 +281,7 @@ export function startUpdatePoller(): void {
pollerStarted = true
void checkUpdates()
void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info))
void refreshDesktopVersion()
bridge.onProgress(ingestProgress)
window.addEventListener('focus', onFocus)
@@ -275,4 +307,8 @@ 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()
}
+35
View File
@@ -641,6 +641,41 @@ 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']
+19 -4
View File
@@ -107,14 +107,17 @@ 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 to restore
the previous values when the handler exits.
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.
Returns a list of ``Token`` objects (one per variable) that can be
passed to ``clear_session_vars``.
``cwd`` pins the logical working directory for this context.
"""
tokens = [
_SESSION_PLATFORM.set(platform),
@@ -126,6 +129,12 @@ 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
@@ -151,6 +160,12 @@ 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:
+3 -1
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) -> None:
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: 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,6 +3380,8 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None
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)
+7 -15
View File
@@ -307,28 +307,20 @@ 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),
)
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"),
auth_mod._save_codex_tokens(
creds["tokens"],
last_refresh=creds.get("last_refresh"),
label=label,
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
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}"')
return
if provider == "xai-oauth":
+210 -29
View File
@@ -201,6 +201,7 @@ if _try_termux_ultrafast_version():
raise SystemExit(0)
import argparse
import hashlib
import json
import shutil
import subprocess
@@ -6869,6 +6870,147 @@ 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"
@@ -6928,8 +7070,7 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
except Exception as exc:
print(f" (warning: macOS relaunch fixup skipped: {exc})")
def cmd_gui(args):
def cmd_gui(args: argparse.Namespace):
"""Build and launch the native Electron desktop GUI."""
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
if not (desktop_dir / "package.json").exists():
@@ -6954,6 +7095,8 @@ def cmd_gui(args):
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:
@@ -6965,7 +7108,7 @@ def cmd_gui(args):
else:
npm = None
if getattr(args, "skip_build", False):
if skip_build:
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'}")
@@ -6986,27 +7129,41 @@ def cmd_gui(args):
else:
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
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)
# 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)
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_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-only: produce the artifact but do NOT launch. The installer's
# --update flow drives the rebuild headlessly and then launches the desktop
@@ -9697,6 +9854,25 @@ 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!")
@@ -11364,7 +11540,7 @@ def cmd_dashboard(args):
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
sys.exit(1)
elif getattr(args, "skip_build", False):
# --skip-build trusts the caller to have pre-built the web UI.
# --build-mode skip 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 = (
@@ -14733,11 +14909,6 @@ Examples:
"Electron app, then launches that packaged artifact."
),
)
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",
@@ -14766,6 +14937,16 @@ 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)
# =========================================================================
+6
View File
@@ -241,6 +241,12 @@ _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": [
+6 -25
View File
@@ -3729,31 +3729,12 @@ def _codex_full_login_worker(session_id: str) -> None:
if not access_token:
raise RuntimeError("token exchange did not return access_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)
from hermes_cli.auth import _save_codex_tokens
_save_codex_tokens({
"access_token": access_token,
"refresh_token": refresh_token,
})
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
+125 -1959
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -64,6 +64,8 @@ dependencies = [
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
# `os.killpg` (which doesn't exist on Windows).
"psutil==7.2.2",
# .gitignore-aware file matching for desktop build stamp.
"pathspec==1.1.1",
"fastapi>=0.104.0,<1",
"uvicorn[standard]>=0.24.0,<1",
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
+51 -1
View File
@@ -6,7 +6,12 @@ from pathlib import Path
import pytest
import agent.runtime_cwd as rt
from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
from agent.runtime_cwd import (
clear_session_cwd,
resolve_agent_cwd,
resolve_context_cwd,
set_session_cwd,
)
def _raise_oserror(*args, **kwargs):
@@ -77,3 +82,48 @@ class TestResolveContextCwd:
# than building Path(" ") and resolving garbage under the launch dir.
monkeypatch.setenv("TERMINAL_CWD", " ")
assert resolve_context_cwd() is None
class TestSessionCwdOverride:
"""The #29531 per-session arm: a contextvar cwd wins over TERMINAL_CWD so a
multi-session gateway can pin each session to its own folder."""
def test_session_cwd_overrides_terminal_cwd(self, monkeypatch, tmp_path):
other = tmp_path / "other"
other.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
token = set_session_cwd(str(other))
try:
assert resolve_agent_cwd() == other
assert resolve_context_cwd() == other
finally:
rt._SESSION_CWD.reset(token)
def test_empty_session_cwd_falls_back_to_terminal_cwd(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
token = set_session_cwd("")
try:
assert resolve_agent_cwd() == tmp_path
assert resolve_context_cwd() == tmp_path
finally:
rt._SESSION_CWD.reset(token)
def test_clear_session_cwd_restores_terminal_cwd(self, monkeypatch, tmp_path):
other = tmp_path / "other"
other.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
token = set_session_cwd(str(other))
try:
clear_session_cwd()
assert resolve_agent_cwd() == tmp_path
finally:
rt._SESSION_CWD.reset(token)
def test_nonexistent_session_cwd_falls_back(self, monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
token = set_session_cwd(str(tmp_path / "gone"))
try:
# resolve_agent_cwd guards on isdir; a missing session cwd must not win.
assert resolve_agent_cwd() == tmp_path
finally:
rt._SESSION_CWD.reset(token)
+6 -7
View File
@@ -303,9 +303,11 @@ def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
entries = payload["credential_pool"]["openai-codex"]
entry = next(item for item in entries if item["source"] == "manual:device_code")
entry = next(item for item in entries if item["source"] == "device_code")
assert payload["active_provider"] == "openai-codex"
assert payload["providers"]["openai-codex"]["tokens"]["access_token"] == token
assert entry["label"] == "codex@example.com"
assert entry["source"] == "manual:device_code"
assert entry["source"] == "device_code"
assert entry["refresh_token"] == "refresh-token"
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
@@ -1129,10 +1131,6 @@ def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setattr(
"agent.credential_pool._seed_from_singletons",
lambda provider, entries: (False, set()),
)
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
@@ -1171,7 +1169,8 @@ def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
assert "openai-codex" not in payload.get("suppressed_sources", {})
# New pool entry must be present
entries = payload["credential_pool"]["openai-codex"]
assert any(e["source"] == "manual:device_code" for e in entries)
assert any(e["source"] == "device_code" for e in entries)
assert payload["active_provider"] == "openai-codex"
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
+163
View File
@@ -16,6 +16,8 @@ from hermes_cli import main as cli_main
def _ns(**kw):
defaults = dict(
skip_build=False,
build_only=False,
force_build=False,
source=False,
fake_boot=False,
ignore_existing=False,
@@ -60,6 +62,8 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch):
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
@@ -86,6 +90,8 @@ def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch):
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \
pytest.raises(SystemExit):
@@ -158,6 +164,8 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(source=True))
@@ -179,3 +187,158 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
def test_gui_is_known_builtin_for_plugin_gating(argv):
with patch.object(sys, "argv", argv):
assert cli_main._plugin_cli_discovery_needed() is False
# ── Content-hash stamp tests ──────────────────────────────────────────
def test_desktop_build_stamp_skips_build_when_up_to_date(tmp_path, monkeypatch):
"""When the stamp matches and the artifact exists, build is skipped entirely."""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch)
launch_ok = subprocess.CompletedProcess([], 0)
with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 0
mock_install.assert_not_called()
mock_run.assert_called_once() # only the launch call, no build
def test_desktop_force_build_overrides_stamp(tmp_path, monkeypatch):
"""--force-build forces a rebuild even when the stamp says up-to-date."""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch)
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
launch_ok = subprocess.CompletedProcess([], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(force_build=True))
assert exc.value.code == 0
mock_install.assert_called_once()
mock_stamp.assert_called_once()
# pack + launch = 2 calls
assert mock_run.call_count == 2
def test_compute_desktop_content_hash_stable(tmp_path, monkeypatch):
"""_compute_desktop_content_hash returns the same digest for identical trees."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("console.log('hi')", encoding="utf-8")
(root / "package.json").write_text('{"name":"hermes"}', encoding="utf-8")
(root / "package-lock.json").write_text('{}', encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
h1 = cli_main._compute_desktop_content_hash(root)
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 == h2
assert len(h1) == 64 # sha256 hex
def test_compute_desktop_content_hash_changes_on_edit(tmp_path, monkeypatch):
"""Editing a file under apps/desktop/ changes the hash."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
h1 = cli_main._compute_desktop_content_hash(root)
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 != h2
def test_desktop_build_needed_detects_missing_artifact(tmp_path, monkeypatch):
"""Even with a valid stamp, missing artifact means build is needed."""
root = _make_desktop_tree(tmp_path)
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Write a stamp that matches current content
cli_main._write_desktop_build_stamp(root, source_mode=False)
# No packaged executable exists → build needed
assert cli_main._desktop_build_needed(
root / "apps" / "desktop", root, source_mode=False
) is True
def test_desktop_build_stamp_round_trip(tmp_path, monkeypatch):
"""Write stamp, then _desktop_build_needed returns False when artifact exists."""
root = _make_desktop_tree(tmp_path)
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Create the artifact so the "artifact exists" check passes
_make_packaged_executable(root, monkeypatch)
# Write stamp
cli_main._write_desktop_build_stamp(root, source_mode=False)
# Build should NOT be needed
assert cli_main._desktop_build_needed(
root / "apps" / "desktop", root, source_mode=False
) is False
def test_compute_desktop_content_hash_works_without_gitignore(tmp_path, monkeypatch):
"""When no .gitignore exists, _compute_desktop_content_hash still works (matches everything)."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# No .gitignore → pathspec matches nothing → all files hashed
h = cli_main._compute_desktop_content_hash(root)
assert len(h) == 64 # valid sha256 hex
# Edit a file → hash changes
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
h2 = cli_main._compute_desktop_content_hash(root)
assert h != h2
def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
"""Files matched by .gitignore are excluded from the hash."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("hello", encoding="utf-8")
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=xxx", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
(root / ".gitignore").write_text("*.env\n", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Reset cached spec
cli_main._DESKTOP_STAMP_SPEC = None
h1 = cli_main._compute_desktop_content_hash(root)
# Change the .env file (ignored) — hash should NOT change
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=yyy", encoding="utf-8")
cli_main._DESKTOP_STAMP_SPEC = None # reset since gitignore hasn't changed
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 == h2, "changing an ignored file should not change the hash"
# Change the .js file (not ignored) — hash SHOULD change
(root / "apps" / "desktop" / "main.js").write_text("world", encoding="utf-8")
cli_main._DESKTOP_STAMP_SPEC = None
h3 = cli_main._compute_desktop_content_hash(root)
assert h1 != h3, "changing a tracked file should change the hash"
@@ -146,6 +146,67 @@ def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusa
assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
from hermes_cli.auth import get_active_provider
from hermes_cli.runtime_provider import resolve_runtime_provider
access_token = "h.eyJleHAiOjk5OTk5OTk5OTl9.s"
class _Resp:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
def json(self):
return self._payload
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
if url.endswith("/deviceauth/usercode"):
return _Resp(200, {
"device_auth_id": "device-auth-id",
"interval": 3,
"user_code": "CODEX-1234",
})
if url.endswith("/deviceauth/token"):
return _Resp(200, {
"authorization_code": "authorization-code",
"code_verifier": "code-verifier",
})
return _Resp(200, {
"access_token": access_token,
"refresh_token": "codex-refresh",
})
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(httpx, "Client", _Client)
monkeypatch.setattr(ws.time, "sleep", lambda _: None)
sid, _ = ws._new_oauth_session("openai-codex", "device_code")
try:
ws._codex_full_login_worker(sid)
assert ws._oauth_sessions[sid]["status"] == "approved"
assert get_active_provider() == "openai-codex"
runtime = resolve_runtime_provider(requested=None)
assert runtime["provider"] == "openai-codex"
assert runtime["api_key"] == access_token
assert runtime["api_mode"] == "codex_responses"
finally:
ws._oauth_sessions.pop(sid, None)
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
+49
View File
@@ -10,6 +10,55 @@ from unittest.mock import patch
from tui_gateway import server
def test_session_context_uses_session_cwd(monkeypatch, tmp_path):
"""Desktop/TUI sessions must pin the agent cwd per session.
The gateway process itself is often launched from apps/desktop in dev, so
falling back to os.getcwd() makes agents answer from the desktop app folder
even when the sidebar/session cwd is a real project.
"""
from agent.runtime_cwd import resolve_agent_cwd
sid = "cwd-sid"
session_key = "cwd-key"
project = tmp_path / "project"
project.mkdir()
launcher = tmp_path / "apps" / "desktop"
launcher.mkdir(parents=True)
server._sessions[sid] = {"session_key": session_key, "cwd": str(project)}
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(launcher)
tokens = server._set_session_context(session_key)
try:
assert resolve_agent_cwd() == project
finally:
server._clear_session_context(tokens)
server._sessions.pop(sid, None)
def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path):
"""Background/preview tasks use ephemeral ids absent from `_sessions`, so the
parent workspace is passed explicitly; it must pin instead of clearing back
to the gateway launch dir."""
from agent.runtime_cwd import resolve_agent_cwd
project = tmp_path / "project"
project.mkdir()
launcher = tmp_path / "apps" / "desktop"
launcher.mkdir(parents=True)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(launcher)
tokens = server._set_session_context("bg_deadbe", cwd=str(project))
try:
assert resolve_agent_cwd() == project
finally:
server._clear_session_context(tokens)
class _ChunkyStdout:
def __init__(self):
self.parts: list[str] = []
+81
View File
@@ -3,6 +3,7 @@ import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tools.mcp_tool import MCPServerTask, _format_connect_error, _resolve_stdio_command, _MCP_AVAILABLE
@@ -126,3 +127,83 @@ def test_run_stdio_uses_resolved_command_and_prepended_path(tmp_path):
await server.shutdown()
asyncio.run(_test())
# ---------------------------------------------------------------------------
# Regression tests for #37589: Desktop/launchd processes inherit a minimal
# PATH on macOS that does not include ~/.local/bin, /opt/homebrew/bin, or
# /usr/local/bin. The resolver must locate uv/uvx (the dominant MCP server
# runtime for Python projects) under those locations.
# ---------------------------------------------------------------------------
def test_resolve_stdio_command_finds_uvx_in_user_local_bin(tmp_path):
"""uv's official installer drops uv/uvx at ``~/.local/bin/uvx`` on
macOS and Linux. The resolver must pick it up when the GUI PATH
doesn't include that directory (#37589)."""
local_bin = tmp_path / ".local" / "bin"
local_bin.mkdir(parents=True)
uvx_path = local_bin / "uvx"
uvx_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
uvx_path.chmod(0o755)
with patch("tools.mcp_tool.shutil.which", return_value=None), \
patch("os.path.expanduser", lambda p: p.replace("~", str(tmp_path)) if p == "~" else p):
command, env = _resolve_stdio_command("uvx", {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"})
assert command == str(uvx_path)
# The resolver prepended the chosen bin so uvx's shebang-resolved
# children (uv itself, python) can be found in the same directory.
assert env["PATH"].split(os.pathsep)[0] == str(local_bin)
def test_resolve_stdio_command_uvx_unchanged_when_already_on_path():
"""shutil.which hit must still take precedence — don't double-resolve
a working bare command on PATH into something else."""
resolved_path = "/some/path/uvx"
with patch("tools.mcp_tool.shutil.which", return_value=resolved_path):
command, _env = _resolve_stdio_command("uvx", {"PATH": "/usr/bin"})
assert command == resolved_path
def test_resolve_stdio_command_skips_unknown_commands():
"""Bare command names outside the npx/npm/node/uv/uvx allowlist must
NOT be matched against the candidate fallback paths that would
produce false positives like rewriting ``command: my-tool`` into a
coincidentally-named file at ``/opt/homebrew/bin/my-tool`` (#37589)."""
with patch("tools.mcp_tool.shutil.which", return_value=None), \
patch("tools.mcp_tool.os.path.expanduser", lambda p: p), \
patch("tools.mcp_tool.os.path.isfile", return_value=True), \
patch("tools.mcp_tool.os.access", return_value=True):
# A command like 'foo' or 'python' must be left alone even if
# the test is faking every candidate as present.
command, _env = _resolve_stdio_command("foo", {"PATH": "/usr/bin:/bin"})
assert command == "foo"
def test_resolve_stdio_command_prefers_managed_uv(tmp_path):
"""Managed uv at $HERMES_HOME/bin/uv should be preferred over
~/.local/bin, /opt/homebrew/bin, and /usr/local/bin MCP servers
must use the same uv as the CLI update path (managed_uv.py)."""
hermes_bin = tmp_path / "bin"
hermes_bin.mkdir()
uv_path = hermes_bin / "uv"
uv_path.write_text("#!/bin/sh\necho uv 0.1.2\n", encoding="utf-8")
uv_path.chmod(0o755)
# Also create a stale uv at ~/.local/bin to verify ordering.
local_bin = tmp_path / ".local" / "bin"
local_bin.mkdir(parents=True)
stale_uv = local_bin / "uv"
stale_uv.write_text("#!/bin/sh\necho stale\n", encoding="utf-8")
stale_uv.chmod(0o755)
with patch("tools.mcp_tool.shutil.which", return_value=None), \
patch("os.path.expanduser", lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p), \
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
command, env = _resolve_stdio_command("uv", {"PATH": "/usr/bin:/bin"})
assert command == str(uv_path)
assert env["PATH"].split(os.pathsep)[0] == str(hermes_bin)
+64 -3
View File
@@ -353,6 +353,16 @@ class FileOperations(ABC):
"""Delete a file. Returns WriteResult with .error set on failure."""
...
def delete_path(self, path: str, recursive: bool = False) -> WriteResult:
"""Cross-platform delete that handles files and (with recursive=True)
directory trees. Default implementation delegates to ``delete_file``
for the non-recursive case; backends with native recursive support
should override.
"""
if recursive:
return WriteResult(error="Recursive delete not implemented for this backend")
return self.delete_file(path)
@abstractmethod
def move_file(self, src: str, dst: str) -> WriteResult:
"""Move/rename a file from src to dst. Returns WriteResult with .error set on failure."""
@@ -1065,13 +1075,64 @@ class ShellFileOperations(FileOperations):
)
def delete_file(self, path: str) -> WriteResult:
"""Delete a file via rm."""
"""Delete a single file.
Cross-platform: runs via ``python -c`` against the terminal env's
Python so it works on Windows shells (``cmd.exe``/PowerShell) that
don't ship ``rm``. Directories are rejected here — use
``delete_path(recursive=True)`` for trees.
"""
return self._python_delete(path, recursive=False)
def delete_path(self, path: str, recursive: bool = False) -> WriteResult:
"""Cross-platform delete that handles files and (with recursive=True)
directory trees. Always preferred over emitting ``rm -rf`` /
``Remove-Item -Recurse`` directly so the same tool call works on
every backend (local / docker / ssh / Windows).
"""
return self._python_delete(path, recursive=recursive)
def _python_delete(self, path: str, recursive: bool) -> WriteResult:
path = self._expand_path(path)
if _is_write_denied(path):
return WriteResult(error=f"Delete denied: {path} is a protected path")
result = self._exec(f"rm -f {self._escape_shell_arg(path)}")
# We can't shell out to ``rm`` here — it doesn't exist on Windows
# ``cmd.exe`` or PowerShell, so this code path is what's left when
# the backend's terminal is a Windows shell. Path is baked into the
# snippet via ``repr()`` so quoting is correct on every shell.
snippet = (
"import shutil, pathlib, sys\n"
f"p = pathlib.Path({path!r})\n"
f"recursive = {bool(recursive)!r}\n"
"try:\n"
" if p.is_dir() and not p.is_symlink():\n"
" if recursive:\n"
" shutil.rmtree(p)\n"
" else:\n"
" print('is a directory: ' + str(p), file=sys.stderr); sys.exit(2)\n"
" else:\n"
# NOTE: avoid ``unlink(missing_ok=True)`` — that kwarg lands in
# Python 3.8 and the remote interpreter (docker/ssh) may still
# be 3.7 on older distros. The FileNotFoundError handler below
# covers the same case and works back to 3.4.
" p.unlink()\n"
"except FileNotFoundError:\n"
" pass\n"
"except Exception as exc:\n"
" print(str(exc), file=sys.stderr); sys.exit(1)\n"
)
result = self._exec(f"python3 -c {self._escape_shell_arg(snippet)}")
# Fall back to ``python`` (Windows / older systems where there's no
# ``python3`` symlink but a ``python`` binary is on PATH).
if result.exit_code != 0 and "python3" in (result.stdout or ""):
result = self._exec(f"python -c {self._escape_shell_arg(snippet)}")
if result.exit_code != 0:
return WriteResult(error=f"Failed to delete {path}: {result.stdout}")
return WriteResult(error=f"Failed to delete {path}: {(result.stdout or '').strip() or 'unknown error'}")
return WriteResult()
def move_file(self, src: str, dst: str) -> WriteResult:
+37 -13
View File
@@ -402,8 +402,15 @@ def _prepend_path(env: dict, directory: str) -> dict:
def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
"""Resolve a stdio MCP command against the exact subprocess environment.
This primarily exists to make bare ``npx``/``npm``/``node`` commands work
reliably even when MCP subprocesses run under a filtered PATH.
This primarily exists to make bare ``npx``/``npm``/``node`` and
``uv``/``uvx`` commands work reliably even when MCP subprocesses run
under a filtered PATH (#37589). On macOS, processes launched from the
GUI / LaunchAgents inherit a minimal PATH (``/usr/bin:/bin:/usr/sbin:/sbin``)
that does not include ``~/.local/bin`` (uv user install), ``/opt/homebrew/bin``
(Apple Silicon Homebrew), or ``/usr/local/bin`` (Intel Homebrew / Linux
from-source), so a bare ``command: uvx`` MCP server fails with ENOENT
at ``execvp`` from Hermes Desktop even though it works from an
interactive Terminal.
"""
resolved_command = os.path.expanduser(str(command).strip())
resolved_env = dict(env or {})
@@ -413,25 +420,42 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
which_hit = shutil.which(resolved_command, path=path_arg)
if which_hit:
resolved_command = which_hit
elif resolved_command in {"npx", "npm", "node"}:
elif resolved_command in {"npx", "npm", "node", "uv", "uvx"}:
hermes_home = os.path.expanduser(
os.getenv(
"HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes")
)
)
candidates = [
# Hermes-bundled Node first so the desktop app's pinned
# runtime wins over a system Node that may be incompatible.
os.path.join(hermes_home, "node", "bin", resolved_command),
# Managed uv: Hermes owns its own uv/uvx at
# $HERMES_HOME/bin/ (see hermes_cli/managed_uv.py).
# Always prefer this over PATH/brew/pip installs so MCP
# servers use the same uv as the CLI update path.
os.path.join(hermes_home, "bin", resolved_command),
# uv's official macOS/Linux installer drops uv/uvx here.
# This is the dominant install location for developers
# on Apple Silicon and Ubuntu. The ``uv`` user installer
# is documented to land here.
os.path.join(os.path.expanduser("~"), ".local", "bin", resolved_command),
# /usr/local/bin is the canonical install location for Node on
# Linux from-source builds, the upstream node:bookworm-slim
# image (which the Hermes Docker image copies node + npm +
# corepack from since #4977), and macOS Homebrew on Intel.
# Without this candidate, any MCP server configured with an
# env.PATH that omits /usr/local/bin (a common pattern when
# users hand-author PATH for sandboxing) fails with ENOENT
# at execvp, and a naive symlink workaround into the user's
# PATH only fails one layer deeper because npx's shebang
# re-execs /usr/bin/env node which needs the same directory.
# /opt/homebrew/bin — Apple Silicon Homebrew. uv is
# installable via `brew install uv`, and a non-trivial
# share of macOS users (including the original bug
# reporter) have it here.
os.path.join(os.sep, "opt", "homebrew", "bin", resolved_command),
# /usr/local/bin — the canonical install location for
# Node on Linux from-source builds, the upstream
# node:bookworm-slim image (which the Hermes Docker
# image copies node + npm + corepack from since #4977),
# and macOS Homebrew on Intel. Without this candidate,
# any MCP server configured with an env.PATH that omits
# /usr/local/bin (a common pattern when users hand-author
# PATH for sandboxing) fails with ENOENT at execvp, and
# a naive symlink workaround into the user's PATH only
# fails one layer deeper because npx's shebang re-execs
# /usr/bin/env node which needs the same directory.
os.path.join(os.sep, "usr", "local", "bin", resolved_command),
]
for candidate in candidates:
+39 -7
View File
@@ -809,11 +809,31 @@ def _save_cfg(cfg: dict):
_cfg_mtime = None
def _set_session_context(session_key: str) -> list:
def _cwd_for_session_key(session_key: str) -> str:
"""Reverse-map session_key to the session's logical cwd.
Snapshots ``_sessions`` first: concurrent RPC handlers mutate it from the
thread pool, so iterating the live view risks ``RuntimeError: dictionary
changed size during iteration``.
"""
if not session_key:
return ""
for sess in list(_sessions.values()):
if sess.get("session_key") == session_key:
return str(sess.get("cwd") or "")
return ""
def _set_session_context(session_key: str, cwd: str | None = None) -> list:
try:
from gateway.session_context import set_session_vars
return set_session_vars(session_key=session_key)
# Ephemeral task IDs (background, preview) aren't in `_sessions`, so the
# reverse-map returns "" and would clear the cwd override. Callers that
# know the parent workspace pass it explicitly so spawned agents inherit
# it instead of falling back to the gateway launch dir.
resolved = cwd if cwd is not None else _cwd_for_session_key(session_key)
return set_session_vars(session_key=session_key, cwd=resolved)
except Exception:
return []
@@ -2764,6 +2784,7 @@ def _(rid, params: dict) -> dict:
explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
except Exception:
explicit_cwd = False
resolved_cwd = _completion_cwd(params)
_enable_gateway_prompts()
ready = threading.Event()
@@ -2782,7 +2803,7 @@ def _(rid, params: dict) -> dict:
"history_lock": threading.Lock(),
"history_version": 0,
"image_counter": 0,
"cwd": _completion_cwd(params),
"cwd": resolved_cwd,
"inflight_turn": None,
"last_active": now,
"pending_title": title or None,
@@ -4624,7 +4645,7 @@ def _(rid, params: dict) -> dict:
task_id = f"bg_{uuid.uuid4().hex[:6]}"
def run():
session_tokens = _set_session_context(task_id)
session_tokens = _set_session_context(task_id, cwd=_session_cwd(session))
try:
from run_agent import AIAgent
@@ -4709,14 +4730,25 @@ def _(rid, params: dict) -> dict:
if line
)
# Normalize defensively: a malformed client path (embedded NUL, etc.) must
# not blow up the whole restart — treat it as "no validated cwd".
try:
preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else ""
if preview_cwd and not os.path.isdir(preview_cwd):
preview_cwd = ""
except Exception:
preview_cwd = ""
def run():
session_tokens = _set_session_context(task_id)
# Pin the validated preview cwd, else the parent workspace — never an
# invalid client path, which would silently fall back to the launch dir.
session_tokens = _set_session_context(task_id, cwd=(preview_cwd or _session_cwd(session)))
try:
from run_agent import AIAgent
from tools.terminal_tool import register_task_env_overrides
if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))):
register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))})
if preview_cwd:
register_task_env_overrides(task_id, {"cwd": preview_cwd})
history_note = (
f" (with {len(parent_history)} parent-session messages of context)"
Generated
+19 -12
View File
@@ -1602,21 +1602,26 @@ version = "0.15.1"
source = { editable = "." }
dependencies = [
{ name = "croniter" },
{ name = "fastapi" },
{ name = "fire" },
{ name = "httpx", extra = ["socks"] },
{ name = "jinja2" },
{ name = "openai" },
{ name = "pathspec" },
{ name = "prompt-toolkit" },
{ name = "psutil" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dotenv" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
{ name = "ruamel-yaml" },
{ name = "tenacity" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
@@ -1632,11 +1637,9 @@ all = [
{ name = "google-auth-httplib2" },
{ name = "google-auth-oauthlib" },
{ name = "mcp" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-timeout" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "ruff" },
{ name = "setuptools" },
{ name = "simple-term-menu" },
@@ -1739,10 +1742,6 @@ modal = [
parallel-web = [
{ name = "parallel-web" },
]
pty = [
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
]
slack = [
{ name = "aiohttp" },
{ name = "slack-bolt" },
@@ -1755,9 +1754,7 @@ termux = [
{ name = "agent-client-protocol" },
{ name = "honcho-ai" },
{ name = "mcp" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "simple-term-menu" },
{ name = "starlette" },
]
@@ -1770,9 +1767,7 @@ termux-all = [
{ name = "google-auth-oauthlib" },
{ name = "honcho-ai" },
{ name = "mcp" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "simple-term-menu" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
@@ -1822,6 +1817,7 @@ requires-dist = [
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" },
{ name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" },
{ name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" },
{ name = "fastapi", specifier = ">=0.104.0,<1" },
{ name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" },
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" },
{ name = "fire", specifier = "==0.7.1" },
@@ -1866,9 +1862,10 @@ requires-dist = [
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
{ name = "pathspec", specifier = "==1.1.1" },
{ name = "prompt-toolkit", specifier = "==3.0.52" },
{ name = "psutil", specifier = "==7.2.2" },
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
@@ -1877,7 +1874,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
{ name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" },
{ name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" },
{ name = "pyyaml", specifier = "==6.0.3" },
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" },
{ name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" },
@@ -1900,6 +1897,7 @@ requires-dist = [
{ name = "tenacity", specifier = "==9.1.4" },
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
@@ -3057,6 +3055,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
+2 -1
View File
@@ -92,8 +92,9 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works
| Flag | Description |
| -------------------- | ----------------------------------------------------------------------------------------- |
| `--skip-build` | Skip npm install/package and launch the existing unpacked app from `apps/desktop/release` |
| `--force-build` | Force a full rebuild even if the content stamp matches |
| `--build-only` | Build the desktop app but do not launch it (used by `hermes update`) |
| `--source` | Launch via `electron .` against `apps/desktop/dist` instead of the packaged app |
| `--build-only` | Build the desktop app but do not launch it (used by the installer's `--update` flow) |
| `--cwd PATH` | Initial project directory for desktop chat sessions (sets `HERMES_DESKTOP_CWD`) |
| `--hermes-root PATH` | Override the Hermes source root the app uses (sets `HERMES_DESKTOP_HERMES_ROOT`) |
| `--ignore-existing` | Force the app to ignore any `hermes` CLI already on `PATH` during backend resolution |