Compare 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
ethernet 3e6b68252f Merge pull request #37518 from NousResearch/bb/desktop-installer-running-instances
Clarify desktop install retry guidance
2026-06-02 13:13:39 -04:00
ethernet 091ef7d304 Merge pull request #37484 from NousResearch/ethie/gui-docs
fix(docs): update desktop app docs
2026-06-02 13:11:36 -04:00
Brooklyn Nicholson 0c29cfd1a6 Clarify desktop install retry guidance 2026-06-02 12:08:39 -05:00
Austin PickettandCursor 6d14a24b79 feat(dashboard): nous-blue theme, bulk sessions, schedule picker (#37383)
* feat(dashboard): nous-blue theme, bulk sessions, schedule picker

Batch of related dashboard improvements gathered on
austin/fix/dashboard-changes:

* Nous Blue theme — faithful port of the LENS_5I overlay system onto
  the existing DashboardTheme. Lifts the foreground inversion layer to
  z-index 200 to fix the long-standing hover / loading visual artifact,
  adds an explicit swatchColors slot so the theme picker shows the
  post-inversion preview, and migrates the legacy "lens-5i" theme key
  from localStorage / API to "nous-blue" on first read.
* Theme-aware series colors: new --series-input-token /
  --series-output-token CSS vars consumed by Analytics + Models
  charts; ToolCall + ModelInfoCard switched to semantic
  --color-success for diff lines and the Tools capability badge.
* Analytics + Models headers: consolidate period selector + refresh
  next to the page title and drop the redundant period badge.
* Bulk session management — "Delete empty (N)" button + per-row
  checkboxes with shift-click range select and a bulk-delete action
  bar. Backed by SessionDB.delete_sessions() /
  delete_empty_sessions() plus POST /api/sessions/bulk-delete and
  DELETE /api/sessions/empty (registered before the templated
  /api/sessions/{session_id} family so they don't get shadowed).
  Hard cap of 500 IDs per bulk request. Full pytest coverage.
* Cron page — human-readable schedule picker (every-interval / daily
  / weekly / monthly / once / custom) replaces the raw cron
  expression input; the job list now renders "Weekly on Mon, Wed,
  Fri at 14:30" instead of "30 14 * * 1,3,5". English-only ordinals
  for monthly schedules so non-English locales don't get incorrect
  suffixes.
* example-dashboard plugin moved from plugins/ to tests/fixtures/ so
  stock installs no longer ship the demo. Tests install it
  dynamically via a pytest fixture that also reorders the FastAPI
  routes.
* i18n: 40+ new keys for the bulk-select UI and schedule
  picker/describer translated across all 16 locales.

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

* refactor(dashboard): dedupe memory provider picker

The memory provider <Select> lived on both /system and /plugins,
writing the same config.yaml field through two different endpoints
with no cross-page refresh. Remove the picker from /system in favor
of a read-only status row + link to /plugins, where it pairs with
the context-engine picker under "Plugin providers".

/system retains the destructive admin controls (file sizes, Reset
MEMORY.md / USER.md / all). The api.setMemoryProvider client and
PUT /api/memory/provider backend endpoint are left in place for
CLI / script callers.

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

* docs(dashboard): address Copilot review on PR #37383

- Backdrop layer-stack comment claimed LENS_5I-style themes override
  --component-backdrop-bg-blend-mode to multiply, but our only
  LENS_5I-style theme (nous-blue) keeps the default difference.
  Reword to describe what the code actually does and present the
  var as a forward-looking extension hook.
- /api/sessions/bulk-delete docstring promised the response would
  echo back the list of deleted IDs, but the implementation only
  returns {ok, deleted}. Tighten the docstring to match the wire
  format; the client already knows what it asked to delete, so the
  IDs aren't needed.

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

* fix(dashboard): address copilot review on cron describe + bulk-select checkbox

- schedule.ts: restrict `describeCronExpression` to strictly 5-field cron
  expressions. The backend `parse_schedule` also accepts the 6-field
  `min hour dom month dow year` form, and humanising those by
  destructuring only the first five fields would silently drop the year
  (e.g. ``0 9 * * * 2099`` rendered as "Daily at 09:00"). 6+ field
  expressions now fall through to the raw-string fallback so the user
  sees what's actually scheduled.

- SessionsPage.tsx (SessionRow): wire the bulk-select Checkbox's
  ``onClick`` directly instead of attaching it to a parent ``<span>``
  with a no-op ``onCheckedChange``. Radix forwards onClick to the
  underlying ``<button role=checkbox>``, so the same handler now drives
  both mouse clicks (preserving shift-key state for range select) and
  keyboard activation (Space on the focused checkbox, which the browser
  synthesises as a click on the <button>). Improves a11y / keyboard UX
  without changing the controlled-selection model.

- SessionsPage.tsx: also extend ``SessionRowProps`` with the new
  ``onRename`` / ``onExport`` props introduced on main so the row's
  destructured prop types resolve after the merge.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 12:37:40 -04:00
ethernet 7450bee8bc fix(docs): update desktop app docs 2026-06-02 11:52:33 -04:00
ethernet a6b6afdff4 Merge pull request #36864 from maxmilian/fix/tui-reset-terminal-input-modes-on-exit
fix(cli): reset terminal input modes on TUI exit to stop focus/mouse leaks
2026-06-02 11:30:50 -04:00
brooklyn! 23c0578bd7 Merge pull request #37462 from NousResearch/bb/desktop-update-throttle
fix(desktop): throttle the update-available toast
2026-06-02 10:26:52 -05:00
Teknium 3eb6bd7f92 docs: add Desktop App guide (#37457)
The native Electron desktop app shipped (PR #20059 and follow-ups) but the
docs only told people how to download it, not what it is or how to use it.

Adds website/docs/user-guide/desktop.md covering install (installer +
prebuilt + Windows GUI), the chat-first UI and management panes, the
hermes desktop CLI flag reference, self-update, how-it-works, and
troubleshooting. Sourced from apps/desktop/README.md, routes.ts, and the
real argparse. Wired into sidebars.ts under Interfaces after the TUI.
2026-06-02 08:09:42 -07:00
brooklyn! f58db77cd0 Merge pull request #37379 from NousResearch/bb/desktop-session-list
feat(desktop): session-list overhaul + cancellable install
2026-06-02 09:56:31 -05:00
brooklyn!andCopilot Autofix powered by AI 8977bf282e Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-02 09:51:51 -05:00
Brooklyn Nicholson 267e7fd395 Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/desktop-session-list 2026-06-02 09:27:34 -05:00
Brooklyn Nicholson d183f75ee0 chore: uptick 2026-06-02 09:27:28 -05:00
Brooklyn Nicholson 4239230957 feat(desktop): cancellable first-launch install
The install overlay had no way to stop a running install — the runner already
supported an abortSignal, but nothing drove it. Wire it end to end:

- main.cjs holds an AbortController for the active runBootstrap and aborts it
  on a new hermes:bootstrap:cancel IPC and on app quit, so quitting/cancelling
  mid-install actually kills install.sh/ps1 instead of orphaning it.
- runBootstrap bails before spawning anything if the signal is already aborted.
- Install overlay gains a "Cancel install" button while a bootstrap is active;
  a cancel surfaces the recovery overlay (retry/repair).

Test: electron/bootstrap-runner.test.cjs asserts the already-aborted early
return (no spawn) via `node --test`.
2026-06-02 08:50:45 -05:00
Jeffrey Quesnelle 927fa7a980 Merge pull request #37330 from NousResearch/desktop/consolidate-models-into-settings
refactor(desktop): move model management from Command Center into Settings
2026-06-02 09:43:10 -04:00
Teknium afea650e16 fix(model-picker): OpenAI shows curated models; OpenRouter no longer phantom-shows (#37404)
The model picker now matches `hermes model` for OpenAI, and OpenRouter
stops appearing as authenticated when only OPENAI_API_KEY is set.

- models.py: provider_model_ids() for the default api.openai.com endpoint
  intersects the live /v1/models dump (120+ entries incl. embeddings,
  whisper, tts, dall-e, moderation, legacy chat) with the curated agentic
  list, preserving curated order. Custom OpenAI-compatible endpoints keep
  the live list verbatim so discovery still works.
- providers.py: drop extra_env_vars=("OPENAI_API_KEY",) from the openrouter
  overlay. list_authenticated_providers reads extra_env_vars to decide
  whether a provider is authenticated, so any OpenAI user saw a phantom
  OpenRouter row. Runtime OpenRouter credential resolution still falls back
  to OPENAI_API_KEY (runtime_provider.py), independent of the overlay.
- Regression tests for both paths.
2026-06-02 06:31:37 -07:00
Teknium 195c4d2a98 feat(streaming): per-platform streaming defaults (Telegram on, Discord off) + dashboard toggles (#37303)
Streaming quality differs sharply by platform: Telegram has native animated
draft streaming (sendMessageDraft) which is smooth, while Discord/Slack only
have edit-based streaming (repeated editMessage) which visibly flickers. Ship
defaults that match reality instead of one global flag.

- hermes_cli/config.py: DEFAULT_CONFIG display.platforms now ships
  telegram.streaming=true and discord.streaming=false (was empty {}). These
  are gap-fillers — config deep-merge has user values win, so anyone who
  explicitly sets discord.streaming=true keeps it. The global
  streaming.enabled master switch still gates everything; these per-platform
  flags only take effect once streaming is on.
- Dashboard exposure comes for free: the web settings schema is generated
  from DEFAULT_CONFIG, so display.platforms.telegram.streaming and
  .discord.streaming now surface as editable boolean toggles in the UI with
  no frontend change. (Previously the per-platform tree was {} and invisible.)
- tests: pin the defaults, the resolver outcome (telegram on / discord off /
  unlisted platforms follow global), user-override-wins, and dashboard schema
  exposure.

No _config_version bump: deep-merge fills the gap for existing installs; no
value migration needed.
2026-06-02 05:52:54 -07:00
Brooklyn Nicholson 5b71f7dd72 feat(desktop): session search in the sidebar
Adds a search box above the session list. Loaded sessions match instantly
client-side; a debounced full-text search (existing /api/sessions/search FTS)
covers the rest so all sessions stay findable at 699+. Results replace the
pinned/agents sections while a query is active and resume on click.
2026-06-02 07:21:03 -05:00
Brooklyn Nicholson 135c65093a feat(desktop): stable in-workspace ordering + No-workspace default
- Sidebar: rows within a workspace group now sort by creation time instead of
  last activity, so they stop reshuffling every time a message lands (muscle
  memory). Groups still float up by recency.
- Sessions only persist a workspace cwd when one was explicitly chosen; an
  auto-detected launch directory is no longer stamped on the row, so untargeted
  sessions group under "No workspace" instead of "desktop". The agent still
  runs in the detected directory.
2026-06-02 07:18:47 -05:00
Brooklyn Nicholson de8bdf529d fix(desktop): keep pinned + recent sessions visible across compression
Long-running sessions auto-compress: the gateway ends the original session
and surfaces the live continuation under a new id (list_sessions_rich projects
the root forward to its tip). Two symptoms fell out of the id rotation:

- A pinned session "vanished" — the pin is stored as the pre-compression root
  id, but the sidebar only matched on the live id, so it was filtered out.
  Pins now resolve on the durable lineage-root id (`_lineage_root_id`, already
  surfaced by the projection): the sidebar indexes sessions by both ids, pin/
  unpin and reorder operate on the durable id, and `sessionPinId()` is shared
  with the Cmd+P toggle. Existing pins keep working with no migration.

- A freshly-continued session was missing from the list until you ungrouped +
  "load 50 more" — the list paginated by original start time, so an old-but-
  active conversation sat past the first page. The desktop now requests
  `order=recent` (GET /api/sessions gains an `order` param backed by the
  existing recency CTE), surfacing live continuations on the first page.
2026-06-02 07:12:05 -05:00
Ben Barclay c10ccaaf51 feat(dashboard-auth): rotate dashboard sessions via refresh token (#37247)
* feat(dashboard-auth): rotate dashboard sessions via refresh token

The dashboard auth-code grant now issues a 24h rotating refresh token
(server side: NousResearch/nous-account-service#293). This wires up the
Hermes client half so an expired access token is transparently refreshed
instead of bouncing the user to /login every 15 minutes.

plugins/dashboard_auth/nous:
- refresh_session() now POSTs grant_type=refresh_token to Portal's token
  endpoint and returns a Session carrying the ROTATED refresh token (was
  an unconditional RefreshExpiredError under the old "no RT in V1"
  contract). The RT is sent in BOTH the request body (Portal's schema
  requires it there) and the X-Refresh-Token header (log redaction) —
  verified against the #293 preview deploy: header-only is rejected as
  invalid_request, body is accepted.
- A 400 from Portal (expired / revoked / reuse-detected) maps to
  RefreshExpiredError so the middleware forces a clean re-login; network
  errors map to ProviderError; empty RT fast-fails without a network call.
- complete_login now captures the initial refresh token Portal returns
  (forward-tolerant: empty string if a deploy omits it).
- Extracted the shared token-response handling into
  _token_response_to_session, parameterised on the 400 exception type so
  the auth-code path raises InvalidCodeError and the refresh path raises
  RefreshExpiredError.
- revoke_session stays a best-effort no-op: Portal exposes no public
  token-endpoint revocation grant (revocation is the authenticated
  /sessions UI, keyed by sessionId+userId), so logout is cookie-clearing
  and the 24h session expires on its own. Documented for a future
  revoke grant.

hermes_cli/dashboard_auth/middleware:
- On an expired/invalid access token the gate now attempts refresh via
  the session's RT BEFORE forcing re-login. On success it serves the
  request and re-sets the rotated cookies on the response (mandatory:
  Portal rotates the RT every refresh and reuse-detects, so a stale RT
  cookie would revoke the whole session on the next refresh). On
  RefreshExpiredError (or no RT) it falls through to clear-and-relogin.
- ProviderError during refresh (Portal unreachable) forces a clean
  re-login rather than 500-ing the request.
- Uses the existing REFRESH_SUCCESS / REFRESH_FAILURE audit events.

Validation:
- 176 dashboard-auth unit/integration tests pass.
- Live E2E against the #293 preview deploy: refresh_session(bad rt) ->
  RefreshExpiredError through the real token endpoint; live JWKS fetch +
  RS256 verification rejects a forged token; empty-RT fast-fail. The
  successful happy-path rotation is covered by unit tests (a live run
  needs an interactive browser OAuth round trip + registered agent:*
  client).

Depends on: NousResearch/nous-account-service#293 (server-side RT issuance).

* fix(dashboard-auth): use Portal's x-nous-refresh-token header name

The refresh-token header must match Portal's REFRESH_TOKEN_HEADER exactly
("x-nous-refresh-token"); the initial cut used "X-Refresh-Token", which
Portal silently ignores (harmless since the RT is also in the body, which
is what the schema requires — but the header redaction was a no-op).
Confirmed against the NAS token route + re-validated live against the
#293 preview deploy.

* fix(dashboard-auth): refresh session when access-token cookie has been evicted

The gated middleware bounced users to /login the instant the access-token
cookie was absent, without ever consulting the refresh token:

    at, _rt = read_session_cookies(request)
    if not at:
        return _unauth_response(...)   # bailed here

This made transparent refresh effectively dead for the common case. The
access-token cookie is set with Max-Age = access_token_expires_in (~15 min),
so a real browser EVICTS hermes_session_at the moment the token lapses while
hermes_session_rt persists (30-day Max-Age). From that point the browser
sends only the refresh-token cookie — and the old guard rejected it before
_attempt_refresh could run. The _attempt_refresh path only fired for a
present-but-invalid access token, which never happens in a browser.

Fix: only hard-bounce when NEITHER cookie is present. A request carrying
just the refresh token now skips verification (no AT to verify) and flows
into the existing refresh path, which rotates both cookies and serves the
request transparently. A dead/expired RT still raises RefreshExpiredError
and falls through to clear-and-relogin.

This failure mode escaped the original tests + manual refresh button because
both kept the access-token cookie present; only a real browser evicting the
cookie at Max-Age exposes it. Added 3 regression tests covering: AT-evicted +
RT-present (transparent refresh), no-cookies (still bounces), and RT-only with
a dead RT (clean 401, no 500).
2026-06-02 21:16:41 +10:00
emozilla 5e55b35cc8 refactor(desktop): move model management from Command Center into Settings
Command Center's Models section and Settings > Model rendered the same
model state with identical persistence semantics — both write config and
apply to new sessions only (POST /api/model/set). The Command Center UI
was strictly better (provider catalog, curated model lists, friendly
auxiliary-task labels, Nous-gateway auto-routing on main-provider switch),
while Settings > Model was three barebones config fields.

Extract that UI into a shared settings/model-settings.tsx (restyled with
Settings primitives) and render it at the top of Settings > Model: main
model picker via setModelAssignment + the 9 auxiliary task slots with
per-task set-to-main / change / reset-all. model_context_length and
fallback_providers stay as config fields below it; the raw auxiliary.*
keys are dropped from Advanced (now covered by the panel).

Strip the Models section from Command Center entirely (section, state,
handlers, render, nav, search entry) leaving it focused on Sessions /
System / Usage, and move the live store-sync callback (onMainModelChanged)
from CommandCenterView to SettingsView. The composer's per-session model
picker (the only live hot-swap, via /model) is unchanged.
2026-06-02 05:53:15 -04:00
Jeffrey Quesnelle c6501c0f49 Merge pull request #37310 from NousResearch/desktop/consolidate-skills-tools-pane
refactor(desktop): consolidate skills + tools management into one pane
2026-06-02 05:21:15 -04:00
emozilla a2b8e430e8 refactor(desktop): consolidate skills + tools management into one pane
The left-nav Skills pane and Settings > Skills & Tools rendered the same
getSkills()/getToolsets() data with the same helpers and toggles — genuine
duplication that drifted (different default category labels, sort orders).

Make the left pane the single home: it keeps its category-tabbed browsing
and now gains the functional bits it lacked — a real toolset enable/disable
switch (was a read-only pill) and the expandable ToolsetConfigPanel for
provider selection + per-key credential config. Remove the Tools section
from Settings (nav item, view branch, query slot, type union entries) and
delete tools-settings.tsx, migrating its toggle coverage into the skills
pane test. Relabel the entry point to 'Skills & Tools' in the sidebar and
command center.
2026-06-02 05:11:52 -04:00
Teknium d78d77e460 feat(config): surface gateway streaming block in DEFAULT_CONFIG (#37285)
The gateway reads top-level streaming.* with StreamingConfig defaults when the
block is absent, so streaming was invisible — a user with no streaming block
sees responses arrive as single messages and has no way to discover the toggle
short of reading source. This materializes the block in config.yaml so it's
discoverable, with values byte-identical to the dataclass defaults (no behavior
change).

- DEFAULT_CONFIG gains a root-level streaming block (enabled, transport,
  edit_interval, buffer_threshold, cursor, fresh_final_after_seconds), each
  documented inline. Values match gateway/config.py StreamingConfig() exactly.
- _KNOWN_ROOT_KEYS gains 'streaming' so the validator accepts the root key.
- No _config_version bump: load_config deep-merges DEFAULT_CONFIG over user
  YAML, so existing installs pick up the default automatically; no value
  migration needed.

Does NOT touch the setup wizard — streaming stays opt-in, just discoverable.
2026-06-02 01:22:24 -07:00
Jeffrey Quesnelle 89db6c8534 Merge pull request #37283 from NousResearch/fix-toolset-provider-selection-display
fix(desktop): reflect active toolset provider in config panel
2026-06-02 04:05:52 -04:00
Teknium 787936d133 feat(gateway): structured stream-event protocol + Telegram draft formatting parity (#37250)
Introduce a typed agent→gateway delivery contract so the gateway (not the
agent) decides how each streaming event is rendered per platform. Moves toward
smart-agent/smart-gateway separation while reproducing today's behavior exactly
in the base class.

- gateway/stream_events.py: typed event vocabulary (MessageChunk/Stop,
  Commentary, ToolCallChunk/Finished, LongToolHint, GatewayNotice).
- gateway/stream_dispatch.py: GatewayEventDispatcher routes events through the
  adapter; adapters can eat events they can't render (e.g. tool chrome on
  plain-text platforms).
- gateway/platforms/base.py: render_message_event + format_tool_event default
  hooks reproduce the historical emoji/preview tool formatting and consumer
  delegation 1:1; adapters override for native rendering.
- gateway/platforms/telegram.py: send_draft now applies MarkdownV2 (format_message
  + parse_mode) with a plain-text fallback on BadRequest, fixing the jarring
  raw-text→formatted shift when the draft finalizes as a real sendMessage.
- gateway/config.py: default streaming transport edit → auto. Safe globally:
  adapters without draft support report supports_draft_streaming()==False and
  transparently use edit, so only Telegram DMs gain native drafts.

Presentation-only contract — nothing rendered here is persisted to conversation
history, preserving cache/message-flow invariants.
2026-06-02 00:33:50 -07:00
emozilla 134643a2fa fix(desktop): reflect active toolset provider in config panel
The toolset config panel highlighted the first keyless provider (e.g.
Nous Portal) on load instead of the provider actually written to config.
The /api/tools/toolsets/{name}/config endpoint never reported which
provider was active, so the GUI's default-expand logic fell back to
"first configured" — and keyless providers are always "configured".

Backend now annotates each provider with is_active (via the same
_is_provider_active helper the CLI 'hermes tools' picker uses) plus a
top-level active_provider summary. The panel prefers that signal before
falling back to first-configured/first.

Adds a frontend regression test (active provider is expanded on load)
and backend coverage (config reports is_active/active_provider; selecting
a provider round-trips into the next config read).
2026-06-02 03:25:46 -04:00
Max HsuandClaude Opus 4.8 038ed94a6c fix(cli): reset terminal input modes on TUI exit to stop focus/mouse leaks
When the TUI exits via Ctrl+C, SIGTERM/SIGHUP, or a crash, prompt_toolkit's
teardown can be bypassed, leaving DEC 1004 (focus reporting) and 1000/1002/1003
(mouse tracking) enabled. The terminal then emits raw ESC[I/ESC[O focus events
and fragmented SGR mouse reports as visible text in whatever runs next in the
same tab.

_run_cleanup() — the once-only cleanup that runs on every catchable exit path
(atexit-registered + called on the normal/EOF/interrupt exit) — now emits
_TERMINAL_INPUT_MODE_RESET_SEQ (the same disable sequence the in-session leak
recovery already uses) as its FIRST step, so the terminal is usable immediately
on Ctrl+C and a later teardown step raising can't skip it.

The reset is gated on a new _tui_input_modes_active flag (set right before
app.run(), cleared once the modes are disabled) so non-TUI one-shot CLI runs —
which share _run_cleanup via atexit — don't emit codes for modes they never
enabled. Writes to sys.stdout when it's the terminal, else falls back to
/dev/tty. SIGKILL is uncatchable and the kanban worker's os._exit(0) bypasses
atexit, but both are non-TTY/non-TUI so there is nothing to reset there.

Adds tests/cli/test_tui_terminal_reset_on_exit.py (9): emits on a TTY when the
TUI ran, no-ops when the TUI never ran, /dev/tty fallback when stdout is
redirected, no-op when neither is available, swallows stdout errors, flag set
and cleared, and wired into _run_cleanup as the first step even when a later
step raises.

Fixes #36823

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:27:44 +08:00
147 changed files with 9223 additions and 3405 deletions
+2 -2
View File
@@ -49,8 +49,8 @@ hermes-agent/
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
│ ├── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
│ # strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
+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
@@ -482,6 +482,18 @@ async function runBootstrap(opts) {
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
} = opts
// Bail before spawning anything if the user already cancelled — otherwise an
// already-aborted signal would still fetch the manifest (a spawn) before the
// in-loop abort check fires.
if (abortSignal && abortSignal.aborted) {
if (typeof onEvent === 'function') {
try {
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
} catch {}
}
return { ok: false, cancelled: true }
}
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
// Tee every event to the runLog AND the caller's onEvent. This gives us a
@@ -0,0 +1,27 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { runBootstrap } = require('./bootstrap-runner.cjs')
test('runBootstrap bails immediately when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const events = []
const result = await runBootstrap({
installStamp: null,
activeRoot: '/tmp/hermes-runner-test',
sourceRepoRoot: null,
hermesHome: '/tmp/hermes-runner-test',
logRoot: '/tmp/hermes-runner-test',
onEvent: ev => events.push(ev),
abortSignal: controller.signal
})
// Cancelled before any install script is spawned.
assert.deepEqual(result, { ok: false, cancelled: true })
assert.ok(
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
'should emit a cancelled failure event'
)
})
+267 -4
View File
@@ -429,12 +429,22 @@ 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
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
// the renderer's "Reload and retry" path or by quitting the app.
let bootstrapFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
let connectionConfigCache = null
const hermesLog = []
const previewWatchers = new Map()
@@ -525,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
}
@@ -1516,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')
]
@@ -1533,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
@@ -1740,12 +1833,15 @@ async function ensureRuntime(backend) {
})
} catch {}
bootstrapAbortController = new AbortController()
const bootstrapResult = await runBootstrap({
installStamp: backend.installStamp,
activeRoot: backend.activeRoot,
sourceRepoRoot: SOURCE_REPO_ROOT,
hermesHome: HERMES_HOME,
logRoot: path.join(HERMES_HOME, 'logs'),
abortSignal: bootstrapAbortController.signal,
onEvent: ev => {
// Tee every bootstrap event to (a) the desktop log for forensics
// and (b) the renderer for live progress UI. Either may be absent;
@@ -1761,6 +1857,16 @@ async function ensureRuntime(backend) {
writeMarker: writeBootstrapMarker
})
bootstrapAbortController = null
if (bootstrapResult.cancelled) {
const cancelledError = new Error('Hermes install was cancelled.')
cancelledError.isBootstrapFailure = true
cancelledError.bootstrapCancelled = true
bootstrapFailure = cancelledError
throw cancelledError
}
if (!bootstrapResult.ok) {
const bootstrapError = new Error(
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
@@ -2679,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) {
@@ -3206,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 {
@@ -3256,6 +3429,18 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
resetHermesConnection()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:cancel', async () => {
// Renderer's Cancel button during first-launch install. Abort the running
// install script (SIGTERM via the runner's abortSignal). runBootstrap
// resolves with { cancelled: true }, which surfaces the recovery overlay.
if (bootstrapAbortController) {
try {
bootstrapAbortController.abort()
} catch {}
return { ok: true, cancelled: true }
}
return { ok: false, cancelled: false }
})
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
@@ -3344,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
})
@@ -3409,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 () => {
@@ -3718,6 +3950,7 @@ app.whenReady().then(() => {
installMediaPermissions()
registerMediaProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
createWindow()
app.on('activate', () => {
@@ -3725,7 +3958,37 @@ 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) {
try {
bootstrapAbortController.abort()
} catch {}
}
if (desktopLogFlushTimer) {
clearTimeout(desktopLogFlushTimer)
desktopLogFlushTimer = null
+6
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),
@@ -91,6 +96,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
+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>
+2 -1
View File
@@ -32,7 +32,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -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)" />
+219 -38
View File
@@ -17,7 +17,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -33,7 +33,7 @@ import {
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
@@ -54,7 +54,8 @@ import {
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds
$workingSessionIds,
sessionPinId
} from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
@@ -66,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',
@@ -73,7 +80,12 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
icon: props => <Codicon name="robot" {...props} />,
action: 'new-session'
},
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{
id: 'skills',
label: 'Skills & Tools',
icon: props => <Codicon name="symbol-misc" {...props} />,
route: SKILLS_ROUTE
},
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
]
@@ -120,6 +132,31 @@ const baseName = (path: string) =>
.filter(Boolean)
.pop()
// FTS results cover sessions that aren't in the loaded page; synthesize a
// minimal SessionInfo so they render in the same row component (resume works
// by id; the snippet stands in for the preview).
function searchResultToSession(result: SessionSearchResult): SessionInfo {
const ts = result.session_started ?? Date.now() / 1000
return {
archived: false,
cwd: null,
ended_at: null,
id: result.session_id,
input_tokens: 0,
is_active: false,
last_active: ts,
message_count: 0,
model: result.model ?? null,
output_tokens: 0,
preview: result.snippet?.trim() || null,
source: result.source ?? null,
started_at: ts,
title: null,
tool_call_count: 0
}
}
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
@@ -133,6 +170,14 @@ function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
groups.set(id, group)
}
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
return [...groups.values()]
}
@@ -179,6 +224,9 @@ export function ChatSidebar({
const workingSessionIds = useStore($workingSessionIds)
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const trimmedQuery = searchQuery.trim()
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
@@ -189,24 +237,99 @@ export function ChatSidebar({
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
const visiblePinnedIds = useMemo(
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
[pinnedSessionIds, sessionsById]
)
// Index sessions by both their live id and their lineage-root id so a pin
// stored as the pre-compression root resolves to the live continuation tip.
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
for (const s of sessions) {
map.set(s.id, s)
const pinnedSessions = useMemo(
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
[visiblePinnedIds, sessionsById]
)
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
map.set(s._lineage_root_id, s)
}
}
return map
}, [sessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
const out: SessionInfo[] = []
for (const pinId of pinnedSessionIds) {
const session = sessionByAnyId.get(pinId)
if (session && !seen.has(session.id)) {
seen.add(session.id)
out.push(session)
}
}
return out
}, [pinnedSessionIds, sessionByAnyId])
const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
// Full-text search across *all* sessions (not just the loaded page) so 699
// sessions stay findable. Debounced; loaded sessions are matched instantly
// client-side and merged ahead of the server hits.
useEffect(() => {
if (!trimmedQuery) {
setServerMatches([])
return
}
let cancelled = false
const id = window.setTimeout(() => {
void searchSessions(trimmedQuery)
.then(res => {
if (!cancelled) {
setServerMatches(res.results)
}
})
.catch(() => undefined)
}, 200)
return () => {
cancelled = true
window.clearTimeout(id)
}
}, [trimmedQuery])
const searchResults = useMemo(() => {
if (!trimmedQuery) {
return []
}
const needle = trimmedQuery.toLowerCase()
const out = new Map<string, SessionInfo>()
for (const s of sortedSessions) {
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
out.set(s.id, s)
}
}
for (const match of serverMatches) {
if (out.has(match.session_id)) {
continue
}
const loaded = sessionByAnyId.get(match.session_id)
out.set(match.session_id, loaded ?? searchResultToSession(match))
}
return [...out.values()]
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
[sortedSessions, visiblePinnedIdSet]
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
[sortedSessions, pinnedRealIdSet]
)
const agentSessions = useMemo(
@@ -236,7 +359,10 @@ export function ChatSidebar({
return
}
reorderPinnedSession(String(active.id), newIndex)
// Sortable ids are live session ids; the pinned store is keyed by durable
// (lineage-root) ids, so translate before reordering.
const dragged = sessionByAnyId.get(String(active.id))
reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex)
}
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
@@ -318,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]} />
)}
</>
)}
@@ -331,6 +457,56 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<div className="shrink-0 pb-1 pt-1">
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
<input
aria-label="Search sessions"
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearchQuery(event.target.value)}
placeholder="Search sessions…"
type="text"
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
onClick={() => setSearchQuery('')}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
)}
</div>
</div>
)}
{sidebarOpen && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
No sessions match {trimmedQuery}.
</div>
}
label="Results"
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
@@ -352,7 +528,7 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && (
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
@@ -370,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)}
@@ -463,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>
)
}
@@ -536,7 +717,7 @@ function SidebarSessionsSection({
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
session
}
@@ -5,6 +5,7 @@ import { type FC, useCallback, useMemo, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarSessionRow } from './session-row'
@@ -77,7 +78,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id)
}
+6 -370
View File
@@ -3,37 +3,29 @@ import {
IconBookmark,
IconBookmarkFilled,
IconDownload,
IconLoader2,
IconRefresh,
IconSparkles,
IconTrash
} from '@tabler/icons-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
getActionStatus,
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
searchSessions,
setModelAssignment,
updateHermes
} from '@/hermes'
import type {
ActionStatusResponse,
AnalyticsResponse,
AuxiliaryModelsResponse,
ModelOptionProvider,
SessionInfo,
SessionSearchResult as SessionSearchApiResult,
StatusResponse
} from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons'
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
@@ -47,30 +39,9 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from
import { OverlayView } from '../overlays/overlay-view'
import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage'
export type CommandCenterSection = 'sessions' | 'system' | 'usage'
const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[]
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments panel readable; raw task keys (vision, mcp, …)
// are opaque to most users.
interface AuxTaskMeta {
hint: string
key: string
label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
]
const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[]
const USAGE_PERIODS = [7, 30, 90] as const
type UsagePeriod = (typeof USAGE_PERIODS)[number]
@@ -79,7 +50,6 @@ interface CommandCenterViewProps {
initialSection?: CommandCenterSection
onClose: () => void
onDeleteSession: (sessionId: string) => Promise<void>
onMainModelChanged?: (provider: string, model: string) => void
onNavigateRoute: (path: string) => void
onOpenSession: (sessionId: string) => void
}
@@ -87,14 +57,12 @@ interface CommandCenterViewProps {
const SECTION_LABELS: Record<CommandCenterSection, string> = {
sessions: 'Sessions',
system: 'System',
models: 'Models',
usage: 'Usage'
}
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
models: 'Global and auxiliary model controls',
usage: 'Token, cost, and skill activity over time'
}
@@ -115,7 +83,7 @@ interface SectionSearchEntry {
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
{
id: 'nav-messaging',
route: MESSAGING_ROUTE,
@@ -128,7 +96,6 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' },
{ id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' }
]
@@ -216,7 +183,6 @@ export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onMainModelChanged,
onNavigateRoute,
onOpenSession
}: CommandCenterViewProps) {
@@ -233,16 +199,6 @@ export function CommandCenterView({
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('')
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applyingModel, setApplyingModel] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
const [usage, setUsage] = useState<AnalyticsResponse | null>(null)
const [usageLoading, setUsageLoading] = useState(false)
@@ -265,11 +221,6 @@ export function CommandCenterView({
[sessions]
)
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
[providers, selectedProvider]
)
const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>(
() => [
{
@@ -342,29 +293,6 @@ export function CommandCenterView({
}
}, [])
const refreshModels = useCallback(async () => {
setModelsLoading(true)
setModelsError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setModelsLoading(false)
}
}, [])
const refreshUsage = useCallback(async (days: UsagePeriod) => {
const requestId = usageRequestRef.current + 1
usageRequestRef.current = requestId
@@ -430,28 +358,12 @@ export function CommandCenterView({
}
}, [refreshSystem, section, status, systemLoading])
useEffect(() => {
if (section === 'models' && !mainModel && !modelsLoading) {
void refreshModels()
}
}, [mainModel, modelsLoading, refreshModels, section])
useEffect(() => {
if (section === 'usage') {
void refreshUsage(usagePeriod)
}
}, [refreshUsage, section, usagePeriod])
useEffect(() => {
if (!selectedProviderModels.length) {
return
}
if (!selectedProviderModels.includes(selectedModel)) {
setSelectedModel(selectedProviderModels[0])
}
}, [selectedModel, selectedProviderModels])
const showGlobalSearchResults = debouncedQuery.length > 0
const hasGlobalSearchResults = searchGroups.length > 0
const sessionListHasResults = filteredSessions.length > 0
@@ -497,128 +409,6 @@ export function CommandCenterView({
[refreshSystem]
)
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
const result = await setModelAssignment({
model: selectedModel,
provider: selectedProvider,
scope: 'main'
})
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
onMainModelChanged?.(provider, model)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [onMainModelChanged, refreshModels, selectedModel, selectedProvider])
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[mainModel, refreshModels]
)
const applyAuxiliaryDraft = useCallback(
async (task: string) => {
if (!auxDraft.provider || !auxDraft.model) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: auxDraft.model,
provider: auxDraft.provider,
scope: 'auxiliary',
task
})
setEditingAuxTask(null)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[auxDraft, refreshModels]
)
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider =
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
},
[auxiliary, mainModel]
)
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task: '__reset__'
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [mainModel, refreshModels])
const handleSearchSelect = useCallback(
(result: CommandCenterSearchResult) => {
if (result.kind === 'route') {
@@ -658,7 +448,7 @@ export function CommandCenterView({
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
key={value}
label={SECTION_LABELS[value]}
onClick={() => setSection(value)}
@@ -684,12 +474,6 @@ export function CommandCenterView({
{usageLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
{section === 'models' && (
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
{modelsLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
</header>
{showGlobalSearchResults ? (
@@ -844,7 +628,7 @@ export function CommandCenterView({
period={usagePeriod}
usage={usage}
/>
) : section === 'system' ? (
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3 text-sm">
{status ? (
@@ -902,154 +686,6 @@ export function CommandCenterView({
</pre>
</OverlayCard>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3">
{mainModel ? (
<>
<div className="text-sm font-medium text-foreground">Main model</div>
<div className="text-xs text-muted-foreground">
{mainModel.provider} / {mainModel.model}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">Loading model state...</div>
)}
</OverlayCard>
<OverlayCard className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedProvider(event.target.value)}
value={selectedProvider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedModel(event.target.value)}
value={selectedModel}
>
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!selectedProvider || !selectedModel || applyingModel}
onClick={() => void applyMainModel()}
>
{applyingModel ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<IconSparkles className="mr-1.5 size-3.5" />
)}
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
</div>
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-auto p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void resetAuxiliaryModels()}
tone="subtle"
>
Reset all
</OverlayActionButton>
</div>
<div className="grid gap-1.5">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
return (
<OverlayCard className="px-2 py-1.5" key={meta.key}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-foreground">{meta.label}</span>
<span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span>
</div>
<div className="truncate font-mono text-[0.62rem] text-muted-foreground">
{isAuto
? 'auto · use main model'
: `${current.provider} · ${current.model || '(provider default)'}`}
</div>
</div>
{!isEditing && (
<>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void setAuxiliaryToMain(meta.key)}
tone="subtle"
>
Set to main
</OverlayActionButton>
<OverlayActionButton
disabled={!providers.length || applyingModel}
onClick={() => beginAuxiliaryEdit(meta.key)}
>
Change
</OverlayActionButton>
</>
)}
</div>
{isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<select
className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event =>
setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' }))
}
value={auxDraft.provider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))}
value={auxDraft.model}
>
{(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!auxDraft.provider || !auxDraft.model || applyingModel}
onClick={() => void applyAuxiliaryDraft(meta.key)}
>
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
<OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle">
Cancel
</OverlayActionButton>
</div>
)}
</OverlayCard>
)
})}
</div>
</OverlayCard>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
+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}>
+28 -14
View File
@@ -32,6 +32,8 @@ import {
$freshDraftReady,
$gatewayState,
$selectedStoredSessionId,
$sessions,
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
@@ -224,10 +226,14 @@ export function DesktopController() {
return
}
if ($pinnedSessionIds.get().includes(sessionId)) {
unpinSession(sessionId)
// Pin on the durable lineage-root id so the pin survives auto-compression.
const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId)
const pinId = session ? sessionPinId(session) : sessionId
if ($pinnedSessionIds.get().includes(pinId)) {
unpinSession(pinId)
} else {
pinSession(sessionId)
pinSession(pinId)
}
}, [])
@@ -366,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)
@@ -531,6 +545,13 @@ export function DesktopController() {
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
</Suspense>
)}
@@ -541,13 +562,6 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
+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'
@@ -18,6 +18,7 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import type { SearchProps } from './types'
@@ -167,10 +168,12 @@ export function ConfigSettings({
query,
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef
}: SearchProps & {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
importInputRef: React.RefObject<HTMLInputElement | null>
}) {
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
@@ -322,6 +325,11 @@ export function ConfigSettings({
return (
<SettingsContent>
{activeSectionId === 'model' && !query.trim() && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{query.trim() && (
<div className="mb-4 text-xs text-muted-foreground">
{fields.length} result{fields.length === 1 ? '' : 's'}
+5 -21
View File
@@ -141,13 +141,7 @@ export const FIELD_LABELS: Record<string, string> = {
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'auxiliary.vision.provider': 'Vision Provider',
'auxiliary.vision.model': 'Vision Model',
'auxiliary.compression.provider': 'Compression Provider',
'auxiliary.compression.model': 'Compression Model',
'auxiliary.title_generation.provider': 'Title Provider',
'auxiliary.title_generation.model': 'Title Model'
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -183,7 +177,7 @@ export const SECTIONS: DesktopConfigSection[] = [
id: 'model',
label: 'Model',
icon: Sparkles,
keys: ['model', 'model_context_length', 'fallback_providers']
keys: ['model_context_length', 'fallback_providers']
},
{
id: 'chat',
@@ -287,13 +281,7 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort',
'auxiliary.vision.provider',
'auxiliary.vision.model',
'auxiliary.compression.provider',
'auxiliary.compression.model',
'auxiliary.title_generation.provider',
'auxiliary.title_generation.model'
'delegation.reasoning_effort'
]
}
]
@@ -311,15 +299,11 @@ export const MODE_OPTIONS: ModeOption[] = [
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<
'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',
string
> = {
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...',
tools: 'Search skills and tools...'
sessions: 'Search archived sessions...'
}
+5 -15
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -20,7 +20,6 @@ import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { SessionsSettings } from './sessions-settings'
import { ToolsSettings } from './tools-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
@@ -29,11 +28,10 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'keys',
'mcp',
'sessions',
'tools',
'about'
]
export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) {
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
@@ -42,8 +40,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
gateway: '',
keys: '',
mcp: '',
sessions: '',
tools: ''
sessions: ''
})
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -140,12 +137,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
label="API Keys"
onClick={() => setActiveView('keys')}
/>
<OverlayNavItem
active={activeView === 'tools'}
icon={Package}
label="Skills & Tools"
onClick={() => setActiveView('tools')}
/>
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
@@ -203,16 +194,15 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
query={queries.config}
/>
) : activeView === 'keys' ? (
<KeysSettings query={queries.keys} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
) : activeView === 'sessions' ? (
<SessionsSettings query={queries.sessions} />
) : (
<ToolsSettings query={queries.tools} />
<SessionsSettings query={queries.sessions} />
)}
</OverlayMain>
</OverlaySplitLayout>
+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}
@@ -0,0 +1,70 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getGlobalModelInfo = vi.fn()
const getGlobalModelOptions = vi.fn()
const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: () => getGlobalModelInfo(),
getGlobalModelOptions: () => getGlobalModelOptions(),
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body)
}))
beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }]
})
getAuxiliaryModels.mockResolvedValue({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderModelSettings() {
const { ModelSettings } = await import('./model-settings')
return render(<ModelSettings />)
}
describe('ModelSettings', () => {
it('loads and shows the current main model', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
expect(screen.getByText('nous / hermes-4')).toBeTruthy()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()
expect(await screen.findByText('Vision')).toBeTruthy()
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
})
it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
await renderModelSettings()
// One "Set to main" button per task slot; the first is Vision.
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
fireEvent.click(setToMainButtons[0])
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'hermes-4',
provider: 'nous',
scope: 'auxiliary',
task: 'vision'
})
)
})
})
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments readable; raw task keys (vision, mcp, …) are
// opaque to most users.
interface AuxTaskMeta {
hint: string
key: string
label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
]
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
interface ModelSettingsProps {
/** Notified after the main model is applied, so live UI stores can sync. */
onMainModelChanged?: (provider: string, model: string) => void
}
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const providerOptions = providers.length ? providers : NO_PROVIDERS
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
[providers, selectedProvider]
)
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
setApplying(true)
setError('')
try {
const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' })
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
onMainModelChanged?.(provider, model)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
}, [onMainModelChanged, refresh, selectedModel, selectedProvider])
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task })
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
},
[mainModel, refresh]
)
const applyAuxiliaryDraft = useCallback(
async (task: string) => {
if (!auxDraft.provider || !auxDraft.model) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task })
setEditingAuxTask(null)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
},
[auxDraft, refresh]
)
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider =
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
},
[auxiliary, mainModel]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task: '__reset__'
})
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
}, [mainModel, refresh])
if (loading && !mainModel) {
return <LoadingState label="Loading model configuration..." />
}
return (
<div className="grid gap-6">
<section>
<SectionHeading
icon={Sparkles}
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
title="Main model"
/>
<p className="mb-3 text-xs text-muted-foreground">
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
</p>
<div className="flex flex-wrap items-center gap-2">
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select onValueChange={setSelectedModel} value={selectedModel}>
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent>
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
</div>
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
</section>
<section>
<div className="mb-2.5 flex items-center justify-between">
<SectionHeading icon={Cpu} title="Auxiliary models" />
<Button
disabled={!mainModel || applying}
onClick={() => void resetAuxiliaryModels()}
size="sm"
variant="outline"
>
Reset all to main
</Button>
</div>
<p className="mb-2 text-xs text-muted-foreground">
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
</p>
<div className="divide-y divide-border/40">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
return (
<ListRow
action={
!isEditing && (
<div className="flex shrink-0 items-center gap-1.5">
<Button
disabled={!mainModel || applying}
onClick={() => void setAuxiliaryToMain(meta.key)}
size="sm"
variant="ghost"
>
Set to main
</Button>
<Button
disabled={!providers.length || applying}
onClick={() => beginAuxiliaryEdit(meta.key)}
size="sm"
variant="outline"
>
Change
</Button>
</div>
)
}
below={
isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
value={auxDraft.provider}
>
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
value={auxDraft.model}
>
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent>
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!auxDraft.provider || !auxDraft.model || applying}
onClick={() => void applyAuxiliaryDraft(meta.key)}
size="sm"
>
{applying ? 'Applying...' : 'Apply'}
</Button>
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
Cancel
</Button>
</div>
)
}
description={
<span className="font-mono text-[0.68rem]">
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
</span>
}
key={meta.key}
title={
<span className="flex items-baseline gap-2">
{meta.label}
<Pill>{meta.hint}</Pill>
</span>
}
/>
)
})}
</div>
</section>
</div>
)
}
@@ -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>
)
}
@@ -1,229 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { Brain, Wrench } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { asText, includesQuery, prettyName, toolNames } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import { ToolsetConfigPanel } from './toolset-config-panel'
import type { SearchProps } from './types'
export function ToolsSettings({ query }: SearchProps) {
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
Promise.all([getSkills(), getToolsets()])
.then(([s, t]) => {
if (cancelled) {
return
}
setSkills(s)
setToolsets(t)
})
.catch(err => notifyError(err, 'Capabilities failed to load'))
return () => void (cancelled = true)
}, [])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
const filteredSkills = useMemo(() => {
if (!skills) {
return []
}
const q = query.trim().toLowerCase()
return skills
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
.sort(
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
)
}, [query, skills])
const filteredToolsets = useMemo(() => {
if (!toolsets) {
return []
}
const q = query.trim().toLowerCase()
return toolsets
.filter(t => {
if (!q) {
return true
}
return (
includesQuery(t.name, q) ||
includesQuery(t.label, q) ||
includesQuery(t.description, q) ||
toolNames(t).some(n => includesQuery(n, q))
)
})
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
}, [query, toolsets])
const skillGroups = useMemo(() => {
const groups = new Map<string, SkillInfo[]>()
for (const skill of filteredSkills) {
const cat = asText(skill.category) || 'other'
groups.set(cat, [...(groups.get(cat) ?? []), skill])
}
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
}, [filteredSkills])
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
setSavingSkill(skill.name)
try {
await toggleSkill(skill.name, enabled)
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
notify({
kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${skill.name}`)
} finally {
setSavingSkill(null)
}
}
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
setSavingToolset(toolset.name)
try {
await toggleToolset(toolset.name, enabled)
setToolsets(c => c?.map(t => (t.name === toolset.name ? { ...t, enabled, available: enabled } : t)) ?? c)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
} finally {
setSavingToolset(null)
}
}
if (!skills || !toolsets) {
return <LoadingState label="Loading skills and toolsets..." />
}
return (
<SettingsContent>
<div className="mb-6">
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
{skillGroups.map(([category, list]) => (
<div className="mt-4 first:mt-0" key={category}>
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-border/40">
{list.map(skill => (
<ListRow
action={
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={c => void handleToggleSkill(skill, c)}
/>
}
description={asText(skill.description)}
key={asText(skill.name)}
title={asText(skill.name)}
/>
))}
</div>
</div>
))}
</div>
<div className="mb-6">
<SectionHeading
icon={Wrench}
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
title="Toolsets"
/>
<div className="divide-y divide-border/40">
{filteredToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
const expanded = expandedToolset === toolset.name
return (
<ListRow
action={
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(c => (c === toolset.name ? null : toolset.name))}
type="button"
>
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</Pill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={c => void handleToggleToolset(toolset, c)}
/>
</div>
}
below={
<>
{tools.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{tools.slice(0, 10).map(t => (
<span
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
key={t}
>
{t}
</span>
))}
{tools.length > 10 && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
+{tools.length - 10} more
</span>
)}
</div>
)}
{expanded && (
<ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />
)}
</>
}
description={asText(toolset.description)}
key={asText(toolset.name) || label}
title={label}
/>
)
})}
</div>
</div>
</SettingsContent>
)
}
@@ -26,6 +26,7 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
return {
name: 'tts',
has_category: true,
active_provider: null,
providers: [
{
name: 'Microsoft Edge TTS',
@@ -33,7 +34,8 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
tag: 'No API key needed',
env_vars: [],
post_setup: null,
requires_nous_auth: false
requires_nous_auth: false,
is_active: false
},
{
name: 'ElevenLabs',
@@ -43,7 +45,8 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
{ key: 'ELEVENLABS_API_KEY', prompt: 'ElevenLabs API key', url: 'https://x', default: null, is_set: false }
],
post_setup: null,
requires_nous_auth: false
requires_nous_auth: false,
is_active: false
}
],
...overrides
@@ -99,4 +102,54 @@ describe('ToolsetConfigPanel', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('ELEVENLABS_API_KEY', 'sk-test-123'))
})
it('expands the active provider on load, not just the first configured one', async () => {
// ElevenLabs is the active provider per config, even though the keyless
// Edge TTS provider sorts first and is also "configured". The panel must
// honor is_active and expand ElevenLabs (so its API-key field renders)
// rather than defaulting to the first keyless provider. Regression test
// for the GUI showing the wrong provider selected after relaunch.
getToolsetConfig.mockResolvedValue(
config({
active_provider: 'ElevenLabs',
providers: [
{
name: 'Microsoft Edge TTS',
badge: 'free',
tag: 'No API key needed',
env_vars: [],
post_setup: null,
requires_nous_auth: false,
is_active: false
},
{
name: 'ElevenLabs',
badge: 'paid',
tag: 'Most natural voices',
env_vars: [
{
key: 'ELEVENLABS_API_KEY',
prompt: 'ElevenLabs API key',
url: 'https://x',
default: null,
is_set: true
}
],
post_setup: null,
requires_nous_auth: false,
is_active: true
}
]
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
// The active provider's env-var field only renders when it's the expanded
// one — so finding it proves ElevenLabs (not Edge TTS) was auto-expanded.
expect(await screen.findByText('ELEVENLABS_API_KEY')).toBeTruthy()
// No provider selection was triggered — this is purely reflecting state.
expect(selectToolsetProvider).not.toHaveBeenCalled()
})
})
@@ -195,16 +195,23 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
// Default the expanded provider to the first one that is fully configured,
// else the first provider.
// Default the expanded provider to the one actually active in config
// (`is_active` / `cfg.active_provider`, mirroring the CLI picker), then the
// first fully-configured provider, else the first provider. Without this the
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
// the user had already selected another (e.g. DuckDuckGo).
useEffect(() => {
if (activeProvider || providers.length === 0) {
return
}
const configured = providers.find(p => providerConfigured(p, envState))
setActiveProvider((configured ?? providers[0]).name)
}, [activeProvider, providers, envState])
const selected =
providers.find(p => p.is_active) ??
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
setActiveProvider(selected.name)
}, [activeProvider, providers, envState, cfg])
async function handleSelect(provider: ToolProvider) {
setActiveProvider(provider.name)
+3 -2
View File
@@ -4,14 +4,15 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
gateway?: HermesGateway | null
onClose: () => void
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
}
export interface SearchProps {
@@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
const SECTIONS = ['models', 'sessions', 'system'] as const
const SECTIONS = ['sessions', 'system', 'usage'] as const
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
export function useOverlayRouting() {
@@ -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))]'
@@ -1,16 +1,24 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getSkills = vi.fn()
const getToolsets = vi.fn()
const toggleSkill = vi.fn()
const toggleToolset = vi.fn()
const getToolsetConfig = vi.fn()
const selectToolsetProvider = vi.fn()
vi.mock('@/hermes', () => ({
getSkills: () => getSkills(),
getToolsets: () => getToolsets(),
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled)
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
getToolsetConfig: (name: string) => getToolsetConfig(name),
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
deleteEnvVar: vi.fn(),
revealEnvVar: vi.fn(),
setEnvVar: vi.fn()
}))
// Notifications hit nanostores/timers we don't care about here.
@@ -32,10 +40,21 @@ function toolset(overrides: Record<string, unknown> = {}) {
}
}
function renderSkills() {
return import('./index').then(({ SkillsView }) =>
render(
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<SkillsView />
</MemoryRouter>
)
)
}
beforeEach(() => {
getSkills.mockResolvedValue([])
getToolsets.mockResolvedValue([toolset()])
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
})
afterEach(() => {
@@ -43,10 +62,9 @@ afterEach(() => {
vi.clearAllMocks()
})
describe('ToolsSettings toolset toggle', () => {
describe('SkillsView toolset management', () => {
it('renders a switch for each toolset and toggles it off', async () => {
const { ToolsSettings } = await import('./tools-settings')
render(<ToolsSettings query="" />)
await renderSkills()
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(sw.getAttribute('aria-checked')).toBe('true')
@@ -57,10 +75,18 @@ describe('ToolsSettings toolset toggle', () => {
})
it('keeps the configured pill alongside the switch', async () => {
const { ToolsSettings } = await import('./tools-settings')
render(<ToolsSettings query="" />)
await renderSkills()
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(screen.getByText('Configured')).toBeTruthy()
})
it('expands the provider config panel when the configured pill is clicked', async () => {
await renderSkills()
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
fireEvent.click(configureBtn)
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
})
})
+50 -6
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
@@ -14,6 +14,7 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
const SKILLS_MODES = ['skills', 'toolsets'] as const
@@ -73,6 +74,8 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
const refreshCapabilities = useCallback(async () => {
setRefreshing(true)
@@ -88,6 +91,12 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
}
}, [])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
useEffect(() => {
void refreshCapabilities()
}, [refreshCapabilities])
@@ -148,6 +157,26 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
}
}
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
setSavingToolset(toolset.name)
try {
await toggleToolset(toolset.name, enabled)
setToolsets(current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
} finally {
setSavingToolset(null)
}
}
return (
<PageSearchShell
{...props}
@@ -248,16 +277,30 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
const expanded = expandedToolset === toolset.name
return (
<div className="px-0 py-2.5" key={toolset.name}>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">{label}</div>
<div className="flex items-center gap-1.5">
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
type="button"
>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
/>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
@@ -275,6 +318,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
))}
</div>
)}
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
</div>
)
})}
@@ -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>
)
}
@@ -62,9 +62,7 @@ function formatStageName(name: string): string {
if (name.length <= 3) return name
return name
.split('-')
.map((word, i) =>
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
)
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
.join(' ')
}
@@ -116,17 +114,10 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
state === 'failed' && 'bg-destructive/10'
)}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
{icon}
</div>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className={cn(
'truncate text-sm font-medium',
state === 'pending' && 'text-muted-foreground'
)}
>
<span className={cn('truncate text-sm font-medium', state === 'pending' && 'text-muted-foreground')}>
{formatStageName(descriptor.name)}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
@@ -135,9 +126,7 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
{state === 'failed' ? STATE_LABEL[state] : null}
</span>
</div>
{reason && state !== 'pending' && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
)}
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
</div>
</li>
)
@@ -180,7 +169,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
durationMs: ev.durationMs ?? null,
// Stamp the start time on the running transition so the UI can show
// a live elapsed timer; preserve it across repeated running events.
startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null,
startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null),
json: ev.json ?? null,
error: ev.error ?? null
}
@@ -217,6 +206,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [now, setNow] = useState(() => Date.now())
const logEndRef = useRef<HTMLDivElement | null>(null)
@@ -293,8 +283,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
<p className="mt-2 text-sm text-muted-foreground">
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and
run the command below, then relaunch this app. Subsequent launches will skip this step.
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the
command below, then relaunch this app. Subsequent launches will skip this step.
</p>
<div className="mt-4">
@@ -328,11 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button
variant="default"
size="sm"
onClick={() => window.location.reload()}
>
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
I{'\u2019'}ve run it -- retry
</Button>
</div>
@@ -362,7 +348,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
{failed
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
'Subsequent launches will skip this step.'}
</p>
@@ -382,10 +368,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full transition-all duration-300',
failed ? 'bg-destructive' : 'bg-primary'
)}
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -431,14 +414,18 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
<span className="ml-1 tabular-nums">
({state.log.length} line{state.log.length === 1 ? '' : 's'})
</span>
</button>
{logOpen && (
<div className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}>
<div
className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}
>
{state.log.length === 0 ? (
<div className="text-muted-foreground">No output yet.</div>
) : (
@@ -457,12 +444,38 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</div>
</div>
{/* Active footer: let the user actually cancel a running install. */}
{state.active && !failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-end">
<Button
disabled={cancelling}
onClick={async () => {
setCancelling(true)
try {
await window.hermesDesktop?.cancelBootstrap?.()
} catch {
// ignore -- the failed/cancelled event will surface the result
}
}}
size="sm"
variant="ghost"
>
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{cancelling ? 'Cancelling...' : 'Cancel install'}
</Button>
</div>
</div>
)}
{/* Footer -- always visible, never scrolls; only renders on failure */}
{failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
Full transcript saved to{' '}
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
</span>
<div className="flex gap-2">
<Button
@@ -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"
+7 -7
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>
@@ -48,6 +53,7 @@ declare global {
getBootstrapState: () => Promise<DesktopBootstrapState>
resetBootstrap: () => Promise<{ ok: boolean }>
repairBootstrap: () => Promise<{ ok: boolean }>
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
@@ -194,12 +200,7 @@ export interface DesktopBootstrapStageDescriptor {
needs_user_input?: boolean
}
export type DesktopBootstrapStageState =
| 'pending'
| 'running'
| 'succeeded'
| 'skipped'
| 'failed'
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
export interface DesktopBootstrapStageResult {
state: DesktopBootstrapStageState
@@ -248,7 +249,6 @@ export type DesktopBootstrapEvent =
docsUrl: string
}
export interface HermesApiRequest {
path: string
method?: string
+3 -2
View File
@@ -114,10 +114,11 @@ export class HermesGateway extends JsonRpcGatewayClient {
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude'
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
})
return {
+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>
)
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { sessionPinId } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
cwd: null,
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 0,
model: null,
output_tokens: 0,
preview: null,
source: null,
started_at: 0,
title: null,
tool_call_count: 0,
...over
})
describe('sessionPinId', () => {
it('uses the live id when there is no compression lineage', () => {
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
})
it('uses the lineage root so a pin survives compression', () => {
// After auto-compression the entry surfaces under a fresh tip id but keeps
// the original root — pinning on the root keeps the pin stable.
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
})
})
+76 -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
@@ -16,6 +21,12 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
}
/** Durable id for pinning. Auto-compression rotates a conversation's session
* id (root -> continuation tip), so pins keyed on the live id evaporate. The
* lineage root is stable across every compression, so we pin on that. */
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
session._lineage_root_id ?? session.id
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
@@ -33,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,
@@ -67,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)
@@ -79,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
@@ -93,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)
}
}
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
const storage = new Map<string, string>()
vi.mock('@/lib/storage', () => ({
persistString: (key: string, value: null | string) => {
if (value === null) {
storage.delete(key)
} else {
storage.set(key, value)
}
},
storedString: (key: string) => storage.get(key) ?? null
}))
const notifySpy = vi.fn()
const dismissSpy = vi.fn()
vi.mock('@/store/notifications', () => ({
notify: (...args: unknown[]) => notifySpy(...args),
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
}))
const { maybeNotifyUpdateAvailable } = await import('./updates')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
supported: true,
behind: 3,
targetSha: 'sha-a',
fetchedAt: 0,
...over
})
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
describe('maybeNotifyUpdateAvailable', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
vi.useRealTimers()
})
it('shows when an update is available and not snoozed', () => {
maybeNotifyUpdateAvailable(status())
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('stays quiet for new commits once the toast was closed', () => {
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// A different commit lands while still within the cooldown window.
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b', behind: 9 }))
expect(notifySpy).not.toHaveBeenCalled()
})
it('re-shows once the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b' }))
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('does nothing when already up to date', () => {
maybeNotifyUpdateAvailable(status({ behind: 0 }))
expect(notifySpy).not.toHaveBeenCalled()
})
})
+62 -19
View File
@@ -48,7 +48,22 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
const UPDATE_TOAST_ID = 'desktop-update-available'
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
// a day, so a "don't show this exact sha again" guard re-popped the toast on
// every new commit. We instead suppress the toast for a cooldown window that
// (re)starts whenever the user closes it.
const UPDATE_TOAST_SNOOZE_KEY = 'hermes:update-toast-snooze-until'
const UPDATE_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeUpdateToast(): void {
persistString(UPDATE_TOAST_SNOOZE_KEY, String(Date.now() + UPDATE_TOAST_COOLDOWN_MS))
}
function isUpdateToastSnoozed(): boolean {
const until = Number(storedString(UPDATE_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
// against. The backend reports its own value in session runtime info; a lower
@@ -74,25 +89,18 @@ export function reportBackendContract(contract: number | undefined): void {
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message:
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
title: 'Backend out of date'
})
}
function markToastDismissed(sha: string | undefined) {
if (sha) {
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
}
}
/**
* Fire a one-shot toast the first time we see a particular target commit so
* users don't have to notice the status-bar version pill turning colors.
* Dismissal is remembered per-target-sha so the toast doesn't keep popping
* back for the same update across restarts.
* Fire a toast when an update is available, at most once per cooldown window.
* Closing the toast dismissing it or opening the updates window from it
* (re)starts the cooldown, so a busy upstream branch doesn't re-spam the user
* on every new commit. The snooze is persisted, so it survives relaunches too.
*/
function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
if (!status || status.supported === false || status.error || !status.targetSha) {
return
}
@@ -101,7 +109,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
return
}
if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) {
if (isUpdateToastSnoozed()) {
return
}
@@ -110,13 +118,12 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
}
const behind = status.behind ?? 0
const targetSha = status.targetSha
notify({
action: {
label: "See what's new",
onClick: () => {
markToastDismissed(targetSha)
snoozeUpdateToast()
openUpdatesWindow()
}
},
@@ -124,7 +131,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
id: UPDATE_TOAST_ID,
kind: 'info',
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
onDismiss: () => markToastDismissed(targetSha),
onDismiss: () => snoozeUpdateToast(),
title: 'Update ready'
})
}
@@ -138,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
@@ -151,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) {
@@ -242,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)
@@ -268,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']
+9
View File
@@ -244,6 +244,10 @@ export interface SessionInfo {
cwd?: null | string
ended_at: null | number
id: string
/** Original root id of a compression chain, when this entry is a projected
* continuation tip. Stable across compressions used as the durable id for
* pins so a pinned conversation survives auto-compression. */
_lineage_root_id?: null | string
input_tokens: number
is_active: boolean
last_active: number
@@ -471,12 +475,17 @@ export interface ToolProvider {
env_vars: ToolEnvVar[]
post_setup: string | null
requires_nous_auth: boolean
/** True when this is the provider currently written to config (mirrors the
* CLI `hermes tools` active-provider detection). */
is_active: boolean
}
export interface ToolsetConfig {
name: string
has_category: boolean
providers: ToolProvider[]
/** Name of the currently active provider, or null if none is configured. */
active_provider: string | null
}
export interface SessionSearchResult {
+64
View File
@@ -872,6 +872,17 @@ _cleanup_done = False
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
# Set True once the TUI's prompt_toolkit app starts (which enables focus
# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI
# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit
# escape codes for modes they never enabled (#36823).
_tui_input_modes_active = False
def _mark_tui_input_modes_active() -> None:
"""Record that the TUI app started, so _run_cleanup resets input modes."""
global _tui_input_modes_active
_tui_input_modes_active = True
def _prepare_deferred_agent_startup() -> None:
@@ -927,6 +938,12 @@ def _run_cleanup():
return
_cleanup_done = True
# Reset terminal input modes first, before the slower resource teardown
# below (MCP / browser / memory shutdown can take seconds). On Ctrl+C the
# user's terminal becomes usable immediately, and a later step raising
# can't skip the reset (#36823). No-op unless the TUI actually ran.
_reset_terminal_input_modes_on_exit()
try:
_cleanup_all_terminals()
except Exception:
@@ -972,6 +989,50 @@ def _run_cleanup():
pass
def _reset_terminal_input_modes_on_exit() -> None:
"""Best-effort: disable focus reporting + mouse tracking on TUI exit so they
don't leak into the next shell session sharing the tab.
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM /
SIGHUP and crashes can bypass its unwind, leaving the modes enabled. The
terminal then emits raw ``ESC[I`` / ``ESC[O`` focus events and fragmented
SGR mouse reports as visible text in whatever runs next in the same tab
(#36823). Called from ``_run_cleanup`` (atexit-registered + invoked on the
normal / EOF / interrupt exit paths) this covers normal quit, Ctrl+C and
SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
``os._exit(0)`` path bypasses ``atexit``; neither runs this but both are
non-TTY / non-TUI, so there is nothing to reset there.
Gated on ``_tui_input_modes_active`` so one-shot non-TUI CLI runs (which
share ``_run_cleanup`` via ``atexit``) never emit these codes. Writes to the
controlling terminal directly: by exit, prompt_toolkit's own output is torn
down, so ``sys.stdout`` is the real fd; falls back to ``/dev/tty`` when
stdout is redirected away from the terminal.
"""
global _tui_input_modes_active
if not _tui_input_modes_active:
return
# About to disable the modes — clear the flag so a re-armed _run_cleanup (or
# a long-lived process that reuses it) doesn't re-emit them.
_tui_input_modes_active = False
# Prefer stdout when it's the terminal; otherwise the TUI may have driven
# /dev/tty while stdout was redirected — reset there instead of nowhere.
try:
stream = sys.stdout
if stream is not None and stream.isatty():
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
stream.flush()
return
except Exception:
pass
try:
with open("/dev/tty", "w", encoding="ascii") as tty:
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
tty.flush()
except Exception:
pass
# =============================================================================
# Git Worktree Isolation (#652)
# =============================================================================
@@ -15135,6 +15196,9 @@ class HermesCLI:
pass # No running loop -- nothing to patch
except Exception:
pass
# The app enables focus reporting + mouse tracking; record that
# so _run_cleanup resets them on exit (#36823).
_mark_tui_input_modes_active()
app.run()
except (EOFError, KeyboardInterrupt, BrokenPipeError):
pass
+11 -4
View File
@@ -361,10 +361,17 @@ class StreamingConfig:
# fall back to edit-based when not.
# "draft" — explicitly request native drafts; falls back to edit when
# the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy/default
# behaviour).
# "edit" — progressive editMessageText only (legacy behaviour).
# "off" — disable streaming entirely.
transport: str = "edit"
#
# Default is "auto": prefer native draft streaming on platforms that
# support it (Telegram DMs via sendMessageDraft, Bot API 9.5+) and fall
# back to edit-based streaming everywhere else. This is safe as a global
# default because adapters without draft support (Discord, Slack, Matrix,
# …) report supports_draft_streaming() == False and transparently use the
# edit path — so "auto" never regresses non-Telegram platforms, it only
# upgrades the chats that can render the smoother native preview.
transport: str = "auto"
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
cursor: str = DEFAULT_STREAMING_CURSOR
@@ -393,7 +400,7 @@ class StreamingConfig:
return cls()
return cls(
enabled=_coerce_bool(data.get("enabled"), False),
transport=data.get("transport", "edit"),
transport=data.get("transport", "auto"),
edit_interval=_coerce_float(
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
),
+78
View File
@@ -1916,6 +1916,84 @@ class BasePlatformAdapter(ABC):
f"{type(self).__name__} does not implement send_draft"
)
# ── Structured stream-event rendering ────────────────────────────────
#
# These methods let an adapter decide *how* to present each structured
# streaming event (see gateway/stream_events.py). The default
# implementations reproduce the historical behavior exactly: assistant
# text/commentary/segment events delegate to the stream consumer, and
# tool events render the same "emoji tool_name: preview" chrome the
# gateway has always produced. Adapters override these to be more native
# to their platform (e.g. Telegram streaming a MarkdownV2 ```bash``` block
# as a draft; iMessage eating tool chrome it cannot format).
#
# The contract is presentation-only: nothing rendered here is persisted to
# conversation history. History is owned by the agent; what an adapter
# chooses to "eat" must never change the bytes the agent stored.
def render_message_event(self, event: Any, sink: Any) -> None:
"""Render a MessageChunk / MessageStop / Commentary onto the sink.
Default: map onto the stream consumer's existing primitives, preserving
today's behavior 1:1. ``sink`` is a GatewayStreamConsumer.
"""
from gateway.stream_events import MessageChunk, MessageStop, Commentary
if isinstance(event, MessageChunk):
if event.text:
sink.on_delta(event.text)
elif isinstance(event, MessageStop):
# An intermediate stop (text → tool → text) is a segment break;
# the terminal stop is signalled by the gateway via finish(),
# not here, so we only break segments on non-final stops.
if not event.final:
sink.on_segment_break()
elif isinstance(event, Commentary):
if event.text:
sink.on_commentary(event.text)
def format_tool_event(self, event: Any, *, mode: str = "all",
preview_max_len: int = 40) -> Optional[str]:
"""Return the rendered chrome for a ToolCallChunk, or None to eat it.
Reproduces the gateway's historical tool-progress formatting: an emoji
for the tool, the tool name, and a short argument preview (or the full
args dict in ``verbose`` mode). Adapters that cannot render tool chrome
(no message editing, plain-text only) should override to return None so
the event is dropped rather than spamming separate bubbles.
``mode`` is the resolved tool-progress mode ("all" / "new" / "verbose");
``preview_max_len`` mirrors the ``tool_preview_length`` config (0 means
"no cap" in verbose mode).
"""
from gateway.stream_events import ToolCallChunk
if not isinstance(event, ToolCallChunk):
return None
from agent.display import get_tool_emoji
emoji = get_tool_emoji(event.tool_name, default="⚙️")
if mode == "verbose":
if event.args:
import json
args_str = json.dumps(event.args, ensure_ascii=False, default=str)
if preview_max_len > 0 and len(args_str) > preview_max_len:
args_str = args_str[:preview_max_len - 3] + "..."
return f"{emoji} {event.tool_name}({list(event.args.keys())})\n{args_str}"
if event.preview:
return f"{emoji} {event.tool_name}: \"{event.preview}\""
return f"{emoji} {event.tool_name}..."
# "all" / "new": short preview, capped (default 40 to keep gateway
# progress bubbles compact — they persist as permanent messages).
preview = event.preview
if preview:
cap = preview_max_len if preview_max_len > 0 else 40
if len(preview) > cap:
preview = preview[:cap - 3] + "..."
return f"{emoji} {event.tool_name}: \"{preview}\""
return f"{emoji} {event.tool_name}..."
@property
def has_fatal_error(self) -> bool:
return self._fatal_error_message is not None
+47 -23
View File
@@ -2521,31 +2521,55 @@ class TelegramAdapter(BasePlatformAdapter):
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"text": text,
}
thread_id = self._metadata_thread_id(metadata)
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
try:
ok = await self._bot.send_message_draft(**kwargs)
if ok:
# Drafts have no message_id; we report success without one
# so the caller knows the animation frame landed.
return SendResult(success=True, message_id=None)
return SendResult(success=False, error="draft_rejected")
except Exception as e:
# Most likely: BadRequest because this bot/chat doesn't allow
# drafts, or a transient server hiccup. The caller treats any
# failure as "fall back to edit-based for this response".
logger.debug(
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
return SendResult(success=False, error=str(e))
# Apply the same MarkdownV2 conversion the regular ``send`` path uses
# so the animated draft preview renders with identical formatting to
# the final message. Without this, the draft streams as raw text and
# the final ``sendMessage`` (which DOES use MarkdownV2) snaps into
# formatted output, producing a jarring visual shift at the end of the
# response. We try MarkdownV2 first and fall back to plain text if a
# malformed escape would be rejected — mirroring the (True, False)
# retry the streaming send loop uses — so a single bad token never
# kills draft streaming for the whole response.
for use_markdown in (True, False):
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"text": self.format_message(text) if use_markdown else text,
}
if use_markdown:
kwargs["parse_mode"] = ParseMode.MARKDOWN_V2
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
try:
ok = await self._bot.send_message_draft(**kwargs)
if ok:
# Drafts have no message_id; we report success without one
# so the caller knows the animation frame landed.
return SendResult(success=True, message_id=None)
return SendResult(success=False, error="draft_rejected")
except Exception as e:
# A MarkdownV2 parse failure (BadRequest "can't parse entities")
# is recoverable: retry once as plain text. Any other failure
# (chat doesn't allow drafts, transient hiccup) — or a failure
# on the plain-text attempt — propagates to the caller, which
# treats it as "fall back to edit-based for this response".
if use_markdown and self._is_bad_request_error(e):
logger.debug(
"[%s] sendMessageDraft MarkdownV2 rejected, retrying "
"as plain text (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
continue
logger.debug(
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
return SendResult(success=False, error=str(e))
return SendResult(success=False, error="draft_rejected")
async def _send_message_with_thread_fallback(self, **kwargs):
"""Send a Telegram message, retrying once without message_thread_id
+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:
+132
View File
@@ -0,0 +1,132 @@
"""Adapter-driven dispatch of structured stream events to a delivery sink.
``GatewayEventDispatcher`` is the seam Tobi asked for: the agent emits typed
events (gateway/stream_events.py), and the *adapter* decides how each one is
delivered. The dispatcher holds an adapter + the stream consumer (sink) + the
resolved per-channel presentation settings (tool-progress mode, preview length)
and routes each event through the adapter's render hooks.
Message/commentary/segment events flow into the consumer (native draft on
Telegram DMs, edit-in-place elsewhere). Tool events are formatted by the
adapter which may return None to *eat* the event on platforms that can't
render tool chrome and the rendered line is enqueued onto the same tool
progress queue the gateway already drains, so the two no longer race through
independent code paths.
This module deliberately has no platform knowledge and no asyncio: it is a thin
synchronous router callable from the agent's worker thread, exactly like the
callbacks it replaces.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional
from gateway.stream_events import (
Commentary,
GatewayNotice,
LongToolHint,
MessageChunk,
MessageStop,
StreamEvent,
ToolCallChunk,
ToolCallFinished,
)
logger = logging.getLogger("gateway.stream_events")
class GatewayEventDispatcher:
"""Route typed stream events through an adapter onto a delivery sink.
Parameters
----------
adapter:
The platform adapter. Provides ``render_message_event`` and
``format_tool_event`` (BasePlatformAdapter defaults reproduce today's
behavior; adapters may override for native rendering).
sink:
The GatewayStreamConsumer for assistant-text delivery. May be None
when streaming is disabled, in which case message events are dropped
(the final response still goes out via the normal send path).
enqueue_tool_line:
Callback that places a rendered tool-progress line onto the gateway's
progress queue (the same queue ``send_progress_messages`` drains). May
be None when tool progress is disabled for this channel.
tool_mode:
Resolved tool-progress mode for this channel ("all" / "new" / "verbose"
/ "off").
preview_max_len:
Resolved ``tool_preview_length`` (0 = no cap in verbose mode).
on_long_tool / on_notice:
Optional hooks for LongToolHint / GatewayNotice events, letting the
gateway own the "should I surface this here?" decision.
"""
def __init__(
self,
adapter: Any,
sink: Any = None,
*,
enqueue_tool_line: Optional[Callable[[Any], None]] = None,
tool_mode: str = "all",
preview_max_len: int = 40,
on_long_tool: Optional[Callable[[LongToolHint], None]] = None,
on_notice: Optional[Callable[[GatewayNotice], None]] = None,
) -> None:
self.adapter = adapter
self.sink = sink
self._enqueue_tool_line = enqueue_tool_line
self.tool_mode = tool_mode or "all"
self.preview_max_len = preview_max_len
self._on_long_tool = on_long_tool
self._on_notice = on_notice
# "new" mode dedup — only report when the tool changes.
self._last_tool: Optional[str] = None
def dispatch(self, event: StreamEvent) -> None:
"""Route a single event. Never raises into the agent's worker thread."""
try:
self._dispatch(event)
except Exception: # presentation must never break the agent loop
logger.debug("stream-event dispatch error", exc_info=True)
def _dispatch(self, event: StreamEvent) -> None:
if isinstance(event, (MessageChunk, MessageStop, Commentary)):
if self.sink is not None:
self.adapter.render_message_event(event, self.sink)
return
if isinstance(event, ToolCallChunk):
if self.tool_mode == "off" or self._enqueue_tool_line is None:
return
# "new" mode: only emit when the tool changes.
if self.tool_mode == "new" and event.tool_name == self._last_tool:
return
self._last_tool = event.tool_name
line = self.adapter.format_tool_event(
event, mode=self.tool_mode, preview_max_len=self.preview_max_len,
)
# None == adapter chose to eat this event (can't render tool chrome).
if line:
self._enqueue_tool_line(line)
return
if isinstance(event, ToolCallFinished):
# Default: no chrome on completion (matches today — the gateway only
# rendered "started" events). Completion drives onboarding hints.
return
if isinstance(event, LongToolHint):
if self._on_long_tool is not None:
self._on_long_tool(event)
return
if isinstance(event, GatewayNotice):
if self._on_notice is not None:
self._on_notice(event)
return
__all__ = ["GatewayEventDispatcher"]
+171
View File
@@ -0,0 +1,171 @@
"""Structured streaming events — the agent→gateway delivery contract.
Historically the agent drove gateway delivery through a fan of loosely-typed
callbacks (``stream_delta_callback(text)``, ``tool_progress_callback(event_type,
tool_name, preview, args)``, ``interim_assistant_callback(text)`` ) and each
gateway callback decided *both* what to render and how to send it. That
coupling is why tool-progress bubbles and the streaming draft raced each other
on Telegram, and why tool-call formatting lived agent-side even though only the
gateway knows what a given platform can render.
This module defines a small, typed event vocabulary that names *what happened*
without prescribing *how it is delivered*. The gateway's stream consumer
(``GatewayStreamConsumer``) is the single sink; the platform adapter decides how
to render each event (Telegram can stream a MarkdownV2 ```bash``` block as a
native draft; iMessage has no rich formatting and may collapse or drop tool
chrome). Separation of concerns: smart agent emits structured data, smart
gateway decides delivery.
These are intentionally plain frozen dataclasses no behavior, no platform
knowledge, no I/O. They are cheap to construct on the agent's worker thread and
safe to hand across the thread/async boundary into the consumer queue.
Design constraints (see hermes-agent-dev skill message-flow + cache
invariants):
* Events describe *transport*, never *context*. Nothing here is persisted to
conversation history; what the gateway chooses to "eat" (e.g. tool chrome on
a platform that can't render it) must never diverge from the bytes stored in
the agent's message history. History is owned by the agent; these events are
a presentation-layer stream only.
* Backward compatible by construction. The gateway adapts its existing
callbacks into these events at the boundary; adapters that don't opt into
event-native rendering get identical behavior via the base-class default.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Union
# ── Message (assistant text) events ──────────────────────────────────────────
@dataclass(frozen=True)
class MessageChunk:
"""A delta of streamed assistant text.
``text`` is the incremental content as it arrives from the model. The
consumer accumulates chunks and progressively renders them (native draft on
Telegram DMs, edit-in-place elsewhere). Reasoning/think-block content is
filtered upstream and never arrives as a MessageChunk.
"""
text: str
@dataclass(frozen=True)
class MessageStop:
"""The current assistant message segment is complete.
Emitted when a contiguous run of assistant text ends either the whole
response finished, or a tool boundary interrupts the text so the next
segment should render as a fresh message *below* any tool chrome.
``final`` is True only for the terminal stop of the whole turn; an
intermediate stop (text tool call more text) carries ``final=False`` so
the consumer finalizes the current bubble and prepares a new segment without
treating the turn as done.
"""
final: bool = False
@dataclass(frozen=True)
class Commentary:
"""A complete interim assistant message emitted between tool iterations.
Example: the model says "I'll inspect the repo first." before issuing a tool
call. Unlike a MessageChunk this is already-complete text (not a delta); the
consumer renders it as its own message so it reads as a distinct beat.
"""
text: str
# ── Tool-call events ─────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ToolCallChunk:
"""A tool invocation has started (or its in-progress state changed).
Carries the raw facts about the call name, a short argument ``preview``,
and the full ``args`` dict and lets the *gateway* decide presentation
(emoji, truncation, verbose vs compact, or eat it entirely on platforms that
don't show tool chrome). Previously the agent's gateway callback baked the
emoji + preview formatting in; that decision now belongs to the adapter.
"""
tool_name: str
preview: Optional[str] = None
args: Optional[Dict[str, Any]] = None
# Monotonic per-turn index, so the consumer can correlate a finish with its
# start and so "new"-mode dedup (only report when the tool changes) works
# without the consumer tracking call order itself.
index: int = 0
@dataclass(frozen=True)
class ToolCallFinished:
"""A tool invocation completed.
``duration`` is wall-clock seconds. ``ok`` reflects whether the tool
returned without raising. The gateway uses this to clear/settle a progress
bubble and to drive one-time onboarding hints (e.g. suggest /verbose after a
long tool run). No tool *output* travels here output is the agent's
concern and is persisted to history, not streamed as presentation.
"""
tool_name: str
duration: float = 0.0
ok: bool = True
index: int = 0
# ── Gateway control / lifecycle events ───────────────────────────────────────
@dataclass(frozen=True)
class LongToolHint:
"""One-shot onboarding nudge when a tool runs longer than the threshold.
The gateway gates this on platform capability (the /verbose command must be
usable) and on the user not having seen the hint before. Modeled as an
event so the *gateway* owns the "should I surface this here?" decision rather
than the agent.
"""
tool_name: str = ""
duration: float = 0.0
@dataclass(frozen=True)
class GatewayNotice:
"""A gateway-originated control message (restart, online, long-run notice).
``kind`` is a stable string the adapter can switch on
(``"restart"`` / ``"online"`` / ``"long_run"`` / ). ``text`` is the
human-readable default the base class renders when an adapter has no
platform-specific treatment.
"""
kind: str
text: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
# Union of every event the consumer's dispatcher accepts. Kept explicit (rather
# than a marker base class) so a missing ``case`` in an exhaustive match is a
# visible type error rather than a silent fall-through.
StreamEvent = Union[
MessageChunk,
MessageStop,
Commentary,
ToolCallChunk,
ToolCallFinished,
LongToolHint,
GatewayNotice,
]
__all__ = [
"MessageChunk",
"MessageStop",
"Commentary",
"ToolCallChunk",
"ToolCallFinished",
"LongToolHint",
"GatewayNotice",
"StreamEvent",
]
+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":
+61 -2
View File
@@ -1345,7 +1345,27 @@ DEFAULT_CONFIG = {
# responses and content messages are never touched. Default 0
# (disabled) preserves prior behavior.
"ephemeral_system_ttl": 0,
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
# Per-platform display/streaming overrides. Each key is a gateway
# platform ("telegram", "discord", "slack", …) mapping to a dict of
# display settings that override the global value for that platform
# only. A setting left unset here falls through to the global default.
#
# Shipped defaults encode the streaming experience that works best
# per platform:
# - Telegram has native animated draft streaming (sendMessageDraft),
# which is smooth, so streaming is on by default there.
# - Discord/Slack/etc. only have edit-based streaming (repeated
# editMessage), which flickers and is noticeably jankier, so
# streaming is off by default there.
# These are gap-fillers: a user who explicitly sets, e.g.,
# display.platforms.discord.streaming: true keeps their value
# (config deep-merge has user values win over defaults). The global
# streaming.enabled master switch still gates everything — these
# per-platform flags only take effect once streaming is enabled.
"platforms": {
"telegram": {"streaming": True},
"discord": {"streaming": False},
},
# Gateway runtime-metadata footer appended to the FINAL message of a turn
# (disabled by default to keep replies minimal). When enabled, renders
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
@@ -2037,6 +2057,45 @@ DEFAULT_CONFIG = {
"trust_recent_files_seconds": 600,
},
# Real-time token streaming to messaging platforms (Telegram, Discord,
# Slack, etc.). Read at the top level by the gateway; absent this block the
# gateway falls back to these same defaults, so adding it here only makes
# the feature discoverable in config.yaml — it does not change behavior.
#
# Disabled by default: streaming costs extra edit/draft API calls per
# response. Set ``enabled: true`` and restart the gateway to turn it on.
"streaming": {
# Master switch. When false, each response is delivered as a single
# final message (no progressive updates).
"enabled": False,
# Transport selection:
# "auto" — prefer native draft streaming where the platform
# supports it (Telegram DMs via sendMessageDraft,
# Bot API 9.5+) and fall back to edit-based elsewhere.
# Safe global default: platforms without draft support
# (Discord, Slack, Matrix, Telegram groups) transparently
# use the edit path, so "auto" only upgrades chats that
# can render the smoother native preview.
# "draft" — explicitly request native drafts; falls back to edit
# when the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy behavior).
# "off" — disable streaming entirely (same as enabled: false).
"transport": "auto",
# Minimum seconds between progressive edits — tuned for Telegram's
# ~1 edit/s flood envelope.
"edit_interval": 0.8,
# Flush the buffer to the platform once this many characters have
# accumulated, so short replies feel near-instant.
"buffer_threshold": 24,
# Cursor glyph appended to the in-progress message while streaming.
"cursor": " \u2589",
# When >0, the final edit for a long-running streamed response is
# delivered as a fresh message if the preview has been visible at
# least this many seconds, so the platform timestamp reflects
# completion time. Telegram only; other platforms ignore it.
"fresh_final_after_seconds": 60.0,
},
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
# state.db accumulates every session, message, tool call, and FTS5 index
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
@@ -3756,7 +3815,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions",
"sessions", "streaming",
}
# Valid fields inside a custom_providers list entry
+132 -24
View File
@@ -24,7 +24,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
from hermes_cli.dashboard_auth import list_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import ProviderError
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
from hermes_cli.dashboard_auth.cookies import read_session_cookies
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
@@ -185,44 +185,94 @@ async def gated_auth_middleware(
return await call_next(request)
at, _rt = read_session_cookies(request)
if not at:
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh; force login.
return _unauth_response(request, reason="no_cookie")
# Try every registered provider's verify_session in turn. Providers
# MUST return None for tokens they don't recognise (not raise). This
# lets multiple providers stack — the first one that recognises a
# token wins.
#
# When the access-token cookie is absent but a refresh-token cookie is
# present, skip verification and go straight to the refresh path below.
# This is the COMMON expiry case, not an edge case: the access-token
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
# the browser EVICTS it the moment the token lapses, while the
# refresh-token cookie lives for 30 days. From that point the browser
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
# bounce the user to /login on every expiry despite holding a perfectly
# good refresh token — defeating the whole transparent-refresh feature.
session = None
for provider in list_providers():
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during verify: %s",
provider.name, e,
)
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {provider.name!r} unreachable"},
status_code=503,
)
if session is not None:
break
if at:
for provider in list_providers():
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during verify: %s",
provider.name, e,
)
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {provider.name!r} unreachable"},
status_code=503,
)
if session is not None:
break
if session is None:
# Access token is expired/invalid. Before forcing re-login, try to
# rotate it using the refresh token (if the session cookie carries
# one). On success we re-set the rotated cookies on the response and
# serve the request transparently; on RefreshExpiredError (RT dead /
# revoked / reuse-detected) we fall through to clear-and-relogin.
refreshed = _attempt_refresh(request, refresh_token=_rt)
if refreshed is not None:
new_session, refreshing_provider = refreshed
request.state.session = new_session
response = await call_next(request)
# Persist the ROTATED tokens. Portal rotates the refresh token on
# every refresh and runs reuse-detection, so writing the new RT
# back is mandatory: a stale RT cookie would replay a rotated
# token on the next refresh and (outside Portal's grace) revoke
# the whole session. Bind cookie Secure/Path to the request shape.
from hermes_cli.dashboard_auth.cookies import (
detect_https,
set_session_cookies,
)
from hermes_cli.dashboard_auth.prefix import prefix_from_request
set_session_cookies(
response,
access_token=new_session.access_token,
refresh_token=new_session.refresh_token,
access_token_expires_in=_expires_in_seconds(new_session),
use_https=detect_https(request),
prefix=prefix_from_request(request),
)
audit_log(
AuditEvent.REFRESH_SUCCESS,
provider=refreshing_provider,
user_id=new_session.user_id,
ip=_client_ip(request),
)
return response
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
reason="no_provider_recognises",
ip=_client_ip(request),
)
response = _unauth_response(request, reason="invalid_or_expired_session")
# Clear the dead cookie so the browser doesn't keep sending it.
# Contract v1: no refresh token to retry with, so the only correct
# Clear the dead cookies so the browser doesn't keep sending them.
# Refresh already failed (or there was no RT), so the only correct
# next step is full re-auth via /login. Importing locally avoids a
# cycle with cookies → middleware at module load. Pass the active
# prefix so the deletion's Path matches the set-Path (otherwise
@@ -234,3 +284,61 @@ async def gated_auth_middleware(
request.state.session = session
return await call_next(request)
def _expires_in_seconds(session) -> int:
"""Seconds until the access token's ``exp``, floored at 60.
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
cookie's Max-Age tracks the token lifetime even on a slightly skewed
clock. ``time`` imported locally to keep the module's import surface
minimal.
"""
import time
return max(60, int(session.expires_at) - int(time.time()))
def _attempt_refresh(request: Request, *, refresh_token):
"""Try to rotate an expired session via the refresh token.
Returns ``(new_session, provider_name)`` on success, or ``None`` if
there's no RT or every provider's ``refresh_session`` failed with
``RefreshExpiredError`` (dead/revoked/reuse-detected RT force re-login).
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
here re-raising would 500 the request; instead we log and return None so
the caller forces a clean re-login, which is the safer UX than a hard
error on a transient network blip during the narrow refresh window.
"""
if not refresh_token:
return None
for provider in list_providers():
try:
new_session = provider.refresh_session(refresh_token=refresh_token)
except RefreshExpiredError:
# This provider owns the RT but it's dead — stop trying others
# (an RT belongs to exactly one provider) and force re-login.
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="refresh_expired",
ip=_client_ip(request),
)
return None
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during refresh: %s",
provider.name, e,
)
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return None
if new_session is not None:
return new_session, provider.name
return None
+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)
# =========================================================================
+29
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": [
@@ -2106,9 +2112,32 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if api_key:
base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
base = base_raw or "https://api.openai.com/v1"
# Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted)
# may serve a small curated catalog — use the live list verbatim so
# discovery works. But the canonical api.openai.com /v1/models dump
# is 120+ entries of embeddings, whisper, tts, dall-e, moderation and
# legacy chat models — none of which belong in the agent model picker.
# For the default endpoint, intersect the live list with our curated
# agentic catalog so ``/model`` matches what ``hermes model`` shows.
is_default_openai = base.rstrip("/") in (
"https://api.openai.com/v1",
"https://api.openai.com",
)
try:
live = fetch_api_models(api_key, base)
if live:
if is_default_openai:
live_lower = {m.lower() for m in live}
curated = list(_PROVIDER_MODELS.get(normalized, []))
# Keep curated order; only surface curated models the
# account actually has access to.
filtered = [m for m in curated if m.lower() in live_lower]
if filtered:
return filtered
# Account serves none of the curated models (rare —
# e.g. org without GPT-5 access). Fall back to curated
# so the picker still offers sane defaults.
return curated or live
return live
except Exception:
pass
-1
View File
@@ -47,7 +47,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
"openrouter": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
extra_env_vars=("OPENAI_API_KEY",),
base_url_env_var="OPENROUTER_BASE_URL",
),
"nous": HermesOverlay(
+142 -25
View File
@@ -1363,6 +1363,7 @@ async def get_sessions(
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
):
"""List sessions.
@@ -1370,12 +1371,22 @@ async def get_sessions(
``exclude`` (default) hides them, ``only`` returns just the archived ones
(used by the desktop "Archived sessions" settings panel), and ``include``
returns both.
``order`` controls pagination order: ``created`` (default, by original
start time) or ``recent`` (by latest activity across the compression
chain). ``recent`` keeps a long-running conversation on the first page
after it auto-compresses into a fresh continuation id.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(
status_code=400,
detail="archived must be one of: exclude, only, include",
)
if order not in ("created", "recent"):
raise HTTPException(
status_code=400,
detail="order must be one of: created, recent",
)
try:
from hermes_state import SessionDB
db = SessionDB()
@@ -1389,6 +1400,7 @@ async def get_sessions(
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
)
total = db.session_count(
min_message_count=min_message_count,
@@ -3717,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)
@@ -3913,6 +3906,117 @@ def _session_latest_descendant(session_id: str):
finally:
db.close()
# CRITICAL — every literal-path route below MUST be declared BEFORE the
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
# Starlette match routes in registration order, and the ``{session_id}``
# pattern is unconstrained — it would otherwise swallow e.g.
# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or
# ``GET /api/sessions/stats`` as "operate on the session with id
# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse,
# succeed and delete the wrong row). Same story as the older
# ``/api/sessions/search`` endpoint up at line ~1191. If you split or
# reorder this block, move every route in it together.
class BulkDeleteSessions(BaseModel):
ids: List[str]
@app.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in a single DB transaction.
Backs the dashboard's bulk-select-and-delete flow on the sessions
page. POST (not DELETE) because most HTTP clients refuse to send a
request body on DELETE and a body is the natural shape for a list
of IDs Starlette accepts both, but POSTing a list keeps proxies,
curl, and the browser ``fetch`` API consistent.
Per-row contract matches :meth:`SessionDB.delete_sessions`:
* Unknown IDs are silently skipped (the response ``deleted`` count
reflects what really happened, not the input length). This is
deliberate UI selection state can race against another tab's
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
batch.
* Children of every deleted parent are orphaned, not cascade-
deleted.
* Active and archived sessions ARE deleted when explicitly
selected unlike ``DELETE /api/sessions/empty``, the user
hand-picked the rows so we trust the selection.
* Like the other session-delete endpoints, this does NOT pass a
``sessions_dir`` through; on-disk transcript / request-dump
cleanup runs at the CLI/agent layer on the next prune pass.
The response carries the actual deleted count, so the dashboard
can surface it in a toast. The IDs that were removed are not
echoed back because the client already knows what it asked to
delete (unknown IDs are silently skipped see contract above)
and can prune its in-memory list directly from the request.
"""
# Enforce a hard cap so a runaway/typo'd selection can't lock the
# DB writer for an extended window. The dashboard pages 20 rows
# at a time; 500 covers a "select all on every page in a
# reasonable scrollback" worst case without opening the door to
# multi-thousand-row transactions.
if len(body.ids) > 500:
raise HTTPException(
status_code=400,
detail="ids must contain at most 500 entries",
)
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_sessions(body.ids)
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint():
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
return {"count": db.count_empty_sessions()}
finally:
db.close()
@app.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint():
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
* Active sessions are skipped (``ended_at IS NULL``) so a live
agent isn't yanked mid-handshake.
* Archived sessions are skipped the user explicitly chose to
keep those rows.
* Children of deleted parents are orphaned, not cascade-deleted.
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
below, this doesn't pass a ``sessions_dir`` through — the on-disk
transcript / request-dump cleanup is wired at the CLI/agent layer
but the web server historically leaves file cleanup to the next
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_empty_sessions()
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/stats")
async def get_session_stats():
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
@@ -3945,6 +4049,7 @@ async def get_session_stats():
finally:
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
@@ -5792,6 +5897,7 @@ async def get_toolset_config(name: str):
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_is_provider_active,
_visible_providers,
)
from hermes_cli.config import get_env_value
@@ -5803,6 +5909,7 @@ async def get_toolset_config(name: str):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
if cat:
for prov in _visible_providers(cat, config, force_fresh=True):
env_vars = [
@@ -5815,6 +5922,13 @@ async def get_toolset_config(name: str):
}
for e in prov.get("env_vars", [])
]
# Surface the same active-provider determination the CLI picker
# uses (``_is_provider_active``) so the GUI highlights the provider
# actually written to config (e.g. web.backend), not just the first
# keyless one in the list.
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
providers.append({
"name": prov["name"],
"badge": prov.get("badge", ""),
@@ -5822,11 +5936,13 @@ async def get_toolset_config(name: str):
"env_vars": env_vars,
"post_setup": prov.get("post_setup"),
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
"is_active": is_active,
})
return {
"name": name,
"has_category": cat is not None,
"providers": providers,
"active_provider": active_provider,
}
@@ -6722,6 +6838,7 @@ def mount_spa(application: FastAPI):
_BUILTIN_DASHBOARD_THEMES = [
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
+172
View File
@@ -3186,6 +3186,178 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_sessions(
self,
session_ids: List[str],
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every session in *session_ids* in a single transaction.
Backs the dashboard's bulk-select-then-delete flow on the
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
single-session :meth:`delete_session` contract per row:
* Unknown IDs are silently skipped (no 404) selection state
in the UI can race against another tab's delete, and we'd
rather succeed-on-the-rest than fail-the-whole-batch.
* Children of every deleted ID are orphaned
(``parent_session_id NULL``), never cascade-deleted, so a
branch / subagent transcript survives an inadvertent parent
delete.
* Messages and the session row both go in one
``_execute_write`` call so a partial failure can't leave the
DB in a "messages gone but session row still there" state.
* On-disk transcript / ``request_dump_*`` files are cleaned up
outside the DB transaction when *sessions_dir* is provided,
matching :meth:`prune_sessions` and
:meth:`delete_empty_sessions`.
Returns the count of sessions that actually existed and were
deleted (may be less than ``len(session_ids)`` if some IDs were
already gone).
"""
if not session_ids:
return 0
# Dedup + drop any non-string entries up-front. Avoids
# double-counting in the WHERE-IN list and protects against
# callers that pass a list with stray ``None`` values.
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
if not unique_ids:
return 0
removed_ids: list[str] = []
def _do(conn):
placeholders = ",".join("?" * len(unique_ids))
# First, filter to IDs that actually exist — we want to
# return the real deleted count, not the input length.
cursor = conn.execute(
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
unique_ids,
)
existing = [row["id"] for row in cursor.fetchall()]
if not existing:
return 0
existing_placeholders = ",".join("?" * len(existing))
# Orphan children whose parent is in the kill list so the
# FK constraint stays satisfied. Pin children whose parent
# is itself in the kill list rather than NULL-ing parents
# of survivors — the IN list on ``parent_session_id`` does
# exactly this.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
existing,
)
removed_ids.extend(existing)
return len(existing)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def count_empty_sessions(self) -> int:
"""Return the count of empty, non-active, non-archived sessions.
"Empty" = ``message_count = 0`` AND the session has ended
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
guard matches the safety contract used by :meth:`prune_sessions`:
only ended sessions are candidates for bulk deletion, so a freshly
spawned session whose first message hasn't landed yet — or one
held open by the live agent is never sniped out from under
the runtime.
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
web dashboard hide its "Delete empty" button when there's nothing
to clean up, and pre-populate the confirm dialog with the actual
count.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
return cursor.fetchone()[0]
def delete_empty_sessions(
self,
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every empty, ended, non-archived session.
Mirrors :meth:`prune_sessions`' transactional shape:
* Selects candidate IDs first (``message_count = 0`` AND
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
touch a live session or one the user deliberately archived.
* Orphans any child whose parent is in the kill list children
of an empty parent are kept and re-parented to ``NULL`` rather
than cascade-deleted, matching ``delete_session`` /
``prune_sessions`` semantics so branch/subagent transcripts
survive an inadvertent parent cleanup.
* Deletes the rows in a single ``_execute_write`` callback so
the operation is atomic a partial failure (e.g. SIGKILL
mid-loop) doesn't leave the DB in a "messages-deleted but
session-row-still-there" half-state.
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) outside the DB transaction when
``sessions_dir`` is provided. Empty sessions don't typically
have transcript files, but the gateway can leave a stub
``request_dump_*`` if it crashed before the first reply
so we still sweep, matching ``prune_sessions``.
Returns the number of sessions deleted.
"""
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
"SELECT id FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
# DELETE FROM messages is paranoia — by construction
# these rows have ``message_count = 0`` — but if a
# bookkeeping bug ever lets the counter drift below the
# real row count, we still leave a clean FK state.
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (sid,)
)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
return len(session_ids)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def prune_sessions(
self,
older_than_days: int = 90,
+125 -1959
View File
File diff suppressed because it is too large Load Diff
+109 -22
View File
@@ -36,8 +36,13 @@ Key contract points encoded here:
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
JWKS is cached for 5 minutes.
- V1 has NO refresh tokens ``refresh_session`` always raises
``RefreshExpiredError`` so the middleware redirects to ``/auth/login``.
- the dashboard auth-code grant issues a 24h rotating refresh token
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
to rotate the access token; ``complete_login`` and ``refresh_session``
both populate ``Session.refresh_token`` with the (rotating) value the
middleware persists back to the HttpOnly cookie. On a dead/expired/
reuse-detected refresh token Portal returns 400 ``RefreshExpiredError``
middleware redirects to ``/auth/login``.
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
- tolerant ``oauth_contract_version`` check: missing warn + proceed;
present and ``!= 1`` refuse.
@@ -49,11 +54,11 @@ of cookie names; this provider just hands back ``{"code_verifier": …,
"state": }`` and the route serializes those into the ``hermes_session_pkce``
cookie.
Forward compatibility: if a future Portal contract starts issuing refresh
tokens, ``complete_login`` already captures the value forward-compatibly
(populates ``Session.refresh_token``). Wiring the RT cookie back into the
middleware's near-expiry refresh path lives in the host application, not
here.
Refresh-token rotation: Portal rotates the refresh token on every
successful refresh and runs reuse-detection (replaying a rotated token
outside Portal's 60s grace revokes the whole session). The host
middleware therefore MUST persist the rotated ``Session.refresh_token``
back to the cookie on every refresh.
Skip reasons:
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
@@ -229,12 +234,94 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
except httpx.RequestError as exc:
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
# The dashboard auth-code grant now issues a rotating refresh token
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
return self._token_response_to_session(
response, bad_request_exc=InvalidCodeError
)
def refresh_session(self, *, refresh_token: str) -> Session:
"""Rotate the access token using the refresh token.
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
so it never lands in Portal's request-body access logs — mirroring the
device-flow CLI convention; Portal reconciles header vs. body and
rejects conflicts.
Portal rotates the refresh token on every successful refresh, so the
returned ``Session.refresh_token`` is a NEW value the caller MUST
persist (replacing the old cookie). Failing to persist it means the
next refresh replays a rotated token and outside Portal's 60s grace
trips reuse-detection and revokes the whole session.
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
detected), so the middleware clears cookies and forces re-login.
Raises ``ProviderError`` if Portal is unreachable.
"""
if not refresh_token:
# No RT to present — treat as a dead session so middleware
# forces a clean re-login rather than emitting a malformed POST.
raise RefreshExpiredError("no refresh token present in session")
try:
response = httpx.post(
self._token_url,
# The refresh token goes in BOTH the body and the
# ``x-nous-refresh-token`` header. Portal's token endpoint
# requires ``refresh_token`` in the body (its request schema
# rejects a header-only request as ``invalid_request``), and
# additionally reconciles the header against the body — sending
# both lets Portal keep the value out of body-access-logs while
# still satisfying the schema. The header name must match
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
# token``); any other name is silently ignored. (Verified
# against the NAS #293 preview deploy: header-only → 400
# invalid_request; body → accepted.)
data={
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": refresh_token,
},
headers={
"Accept": "application/json",
"x-nous-refresh-token": refresh_token,
},
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except httpx.RequestError as exc:
raise ProviderError(
f"Portal token endpoint unreachable: {exc}"
) from exc
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
# surface as RefreshExpiredError so middleware forces re-login.
return self._token_response_to_session(
response, bad_request_exc=RefreshExpiredError
)
def _token_response_to_session(
self,
response: httpx.Response,
*,
bad_request_exc: type[Exception],
) -> Session:
"""Translate a Portal ``/api/oauth/token`` response into a Session.
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
(refresh grant). ``bad_request_exc`` is the exception type raised on a
400 ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
for the refresh path so the middleware's distinct handling
(400-on-callback vs. force-relogin) is preserved.
"""
if response.status_code == 400:
# Contract: invalid_code, invalid_grant, redirect_uri_mismatch all
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
# (auth-code) and expired / revoked / reuse-detected (refresh) all
# surface as 400 with an OAuth-shaped JSON error envelope.
body = self._parse_json_body(response)
error_code = body.get("error", "invalid_request")
raise InvalidCodeError(f"Portal rejected code: {error_code}")
raise bad_request_exc(f"Portal rejected token request: {error_code}")
if response.status_code != 200:
raise ProviderError(
f"Portal token endpoint returned {response.status_code}: "
@@ -251,21 +338,14 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
raise ProviderError(f"unexpected token_type={token_type!r}")
claims = self._verify_jwt(access_token)
# Contract V1: no refresh token expected. If a future Portal ever
# adds one, capture it forward-compatibly.
# The dashboard grant issues a rotating refresh token; capture it so
# the caller can persist it. Empty string if Portal omitted it (the
# session then behaves as access-token-only until expiry).
refresh_token = payload.get("refresh_token") or ""
if not isinstance(refresh_token, str):
refresh_token = ""
return self._session_from_claims(access_token, refresh_token, claims)
def refresh_session(self, *, refresh_token: str) -> Session:
# Contract V1 has no refresh tokens — always force re-auth. If a
# future Portal contract starts issuing them, this method needs to
# be re-implemented; until then it's an unconditional refusal.
raise RefreshExpiredError(
"Nous Portal does not issue refresh tokens in OAuth contract v1; "
"user must re-authenticate via /auth/login."
)
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Contract: returns None on expiry/invalidity (middleware then
@@ -284,9 +364,16 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
return self._session_from_claims(access_token, "", claims)
def revoke_session(self, *, refresh_token: str) -> None:
# Contract V1: no refresh tokens to revoke, and no Portal revocation
# endpoint documented for dashboard tokens. Logout is purely
# client-side cookie clearing; this is a best-effort no-op.
# Portal exposes no public refresh-token revocation grant on its token
# endpoint (revocation is driven from the authenticated /sessions UI,
# keyed by sessionId + userId, not by the RT value). So logout is
# client-side cookie clearing; the server-side refresh session simply
# expires within its 24h TTL. Best-effort no-op, must not raise.
#
# If Portal later adds a token-endpoint revoke grant (e.g.
# grant_type=... + X-Refresh-Token), implement it here so logout
# invalidates the RT server-side immediately rather than waiting out
# the TTL.
_ = refresh_token
return None
@@ -1,17 +0,0 @@
"""Example dashboard plugin — backend API routes.
Mounted at /api/plugins/example/ by the dashboard plugin system.
This minimal plugin exists so the test suite has a stable, side-effect-free
GET endpoint to verify that plugin API routes work with auth.
"""
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
"""Simple greeting endpoint to demonstrate plugin API routes."""
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
+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)
@@ -0,0 +1,212 @@
"""Regression tests for GitHub #36823 — the TUI must reset terminal input
modes on exit so focus-reporting / mouse-tracking escape sequences don't leak
into the next shell session sharing the tab.
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM/SIGHUP
and crashes can bypass its unwind. ``_run_cleanup`` (the once-only cleanup that
runs on every catchable exit path, including ``atexit``) now emits the disable
sequence as its first step via ``_reset_terminal_input_modes_on_exit`` gated
on ``_tui_input_modes_active`` so non-TUI one-shot CLI runs (which share
``_run_cleanup`` via ``atexit``) don't emit codes for modes they never set.
"""
import unittest
from unittest.mock import mock_open, patch
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class _FakeStream:
def __init__(self, isatty: bool = True):
self._isatty = isatty
self.written: list[str] = []
self.flushed = 0
def isatty(self) -> bool:
return self._isatty
def write(self, s: str) -> int:
self.written.append(s)
return len(s)
def flush(self) -> None:
self.flushed += 1
class TestResetTerminalInputModes(unittest.TestCase):
def test_emits_reset_seq_on_tty_when_tui_ran(self):
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
):
cli_mod._reset_terminal_input_modes_on_exit()
written = "".join(fake.written)
self.assertEqual(written, cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
self.assertGreaterEqual(fake.flushed, 1)
# The focus-reporting disable is the specific leak the issue reports.
self.assertIn("\x1b[?1004l", written)
def test_noop_when_tui_never_ran(self):
"""Non-TUI one-shot CLI runs share _run_cleanup via atexit — they must
not emit terminal escape codes they never needed (review finding #1)."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", False),
patch.object(cli_mod.sys, "stdout", fake),
# Guard: must not touch the real /dev/tty either.
patch("builtins.open", mock_open()) as m_open,
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_not_called()
def test_noop_when_not_a_tty_and_no_dev_tty(self):
"""stdout redirected and /dev/tty unavailable → nothing written, no raise."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [], "must not pollute the redirected stream")
def test_falls_back_to_dev_tty_when_stdout_redirected(self):
"""When stdout isn't the terminal, reset via /dev/tty (issue's own
suggestion) so a TUI that drove /dev/tty still gets cleaned up."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
m_open = mock_open()
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", m_open),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_called_once_with("/dev/tty", "w", encoding="ascii")
m_open().write.assert_called_once_with(cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
def test_swallows_stdout_errors(self):
cli_mod = _import_cli()
class _Boom:
def isatty(self):
raise OSError("stdout closed")
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", _Boom()),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
# Cleanup runs at process teardown — it must never raise.
cli_mod._reset_terminal_input_modes_on_exit()
def test_mark_tui_input_modes_active_sets_flag(self):
cli_mod = _import_cli()
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = False
try:
cli_mod._mark_tui_input_modes_active()
self.assertTrue(cli_mod._tui_input_modes_active)
finally:
cli_mod._tui_input_modes_active = original
def test_flag_cleared_after_reset(self):
"""Once the modes are disabled they are no longer active — the flag must
flip back so a re-armed cleanup doesn't re-emit the sequence."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = True
try:
with patch.object(cli_mod.sys, "stdout", fake):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertIn("\x1b[?1004l", "".join(fake.written))
self.assertFalse(
cli_mod._tui_input_modes_active, "flag must clear after reset"
)
finally:
cli_mod._tui_input_modes_active = original
class TestRunCleanupWiring(unittest.TestCase):
"""_run_cleanup must call the reset, as its first step, on every invocation
even if a later cleanup step raises."""
def _run_cleanup_isolated(self, cli_mod, **extra_patches):
"""Invoke _run_cleanup with heavy/real teardown steps stubbed out so the
test is hermetic (review finding #5)."""
original_done = cli_mod._cleanup_done
cli_mod._cleanup_done = False
patches = {
"_cleanup_all_terminals": lambda: None,
"_cleanup_all_browsers": lambda: None,
}
try:
with (
patch.object(
cli_mod, "_reset_terminal_input_modes_on_exit"
) as mock_reset,
patch.object(
cli_mod, "_cleanup_all_terminals", patches["_cleanup_all_terminals"]
),
patch.object(
cli_mod, "_cleanup_all_browsers", patches["_cleanup_all_browsers"]
),
patch("tools.mcp_tool.shutdown_mcp_servers", lambda *a, **k: None),
patch(
"agent.auxiliary_client.shutdown_cached_clients",
lambda *a, **k: None,
),
patch("hermes_cli.plugins.invoke_hook", lambda *a, **k: None),
):
if extra_patches.get("terminals_raise"):
with patch.object(
cli_mod,
"_cleanup_all_terminals",
side_effect=RuntimeError("boom"),
):
cli_mod._run_cleanup()
else:
cli_mod._run_cleanup()
return mock_reset
finally:
cli_mod._cleanup_done = original_done
def test_run_cleanup_calls_reset(self):
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod)
mock_reset.assert_called_once()
def test_reset_runs_even_when_a_cleanup_step_raises(self):
"""The reset is the first step, so a failing teardown step can't skip
it covering the Ctrl+C / crash paths the issue is about."""
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod, terminals_raise=True)
mock_reset.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -1,7 +1,7 @@
{
"name": "example",
"label": "Example",
"description": "Example dashboard plugin — used by test suite for auth coverage",
"description": "Test-only dashboard plugin fixture — installed by tests that need a stable plugin API endpoint to verify auth + static-asset behaviour",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
@@ -0,0 +1,24 @@
"""Example dashboard plugin — backend API routes (test fixture).
This plugin lives under ``tests/fixtures/plugins/`` so it is NOT shipped as
part of the bundled-plugins set; a stock hermes-agent install does not see
an "Example" tab in its sidebar. The ``_install_example_plugin`` pytest
fixture in ``tests/hermes_cli/test_web_server.py`` copies this directory
into ``$HERMES_HOME/plugins/example-dashboard/`` and forces the dashboard
plugin discovery cache to rescan, so tests that need a stable, side-effect-
free GET endpoint to verify plugin API auth + static-asset behaviour can
hit ``/api/plugins/example/hello`` (and ``/dashboard-plugins/example/
manifest.json``) without depending on any production-facing plugin.
Mounted at /api/plugins/example/ by the dashboard plugin system.
"""
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
"""Simple greeting endpoint to demonstrate plugin API routes."""
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
+5 -2
View File
@@ -164,9 +164,12 @@ class TestSessionResetPolicy:
class TestStreamingConfig:
def test_defaults_to_edit_transport(self):
def test_defaults_to_auto_transport(self):
# "auto" prefers native draft streaming where the platform supports
# it (Telegram DMs) and falls back to edit-based everywhere else, so
# it is safe as the global out-of-the-box default.
restored = StreamingConfig.from_dict({"enabled": "true"})
assert restored.transport == "edit"
assert restored.transport == "auto"
def test_from_dict_coerces_quoted_false_enabled(self):
restored = StreamingConfig.from_dict({"enabled": "false"})
@@ -0,0 +1,65 @@
"""Per-platform streaming defaults + dashboard exposure.
Streaming is smooth on Telegram (native sendMessageDraft) but flickers on
edit-only platforms like Discord. The shipped defaults encode that:
display.platforms.telegram.streaming=true, .discord.streaming=false. These are
gap-fillers (user values win via deep-merge) and, because the dashboard schema
is generated from DEFAULT_CONFIG, they automatically appear as editable toggles
in the web UI.
"""
from __future__ import annotations
def test_default_per_platform_streaming_flags():
from hermes_cli.config import DEFAULT_CONFIG
plats = DEFAULT_CONFIG["display"]["platforms"]
assert plats["telegram"]["streaming"] is True
assert plats["discord"]["streaming"] is False
def test_resolver_telegram_on_discord_off_when_global_enabled():
"""With global streaming on, the per-platform defaults make Telegram stream
and Discord not matching the platforms' actual streaming quality."""
from hermes_cli.config import DEFAULT_CONFIG
from gateway.display_config import resolve_display_setting
cfg = dict(DEFAULT_CONFIG)
cfg["streaming"] = {"enabled": True, "transport": "auto"}
def streams(plat):
ov = resolve_display_setting(cfg, plat, "streaming")
# global enabled; None override = follow global (True)
return True if ov is None else bool(ov)
assert streams("telegram") is True
assert streams("discord") is False
# A platform with no default entry follows the global switch.
assert streams("slack") is True
def test_user_override_wins_over_default():
"""A user who explicitly enables Discord streaming keeps their value — the
default false must not clobber it (config deep-merge: user wins)."""
from hermes_cli.config import DEFAULT_CONFIG, _deep_merge
user = {"display": {"platforms": {"discord": {"streaming": True}}}}
merged = _deep_merge(dict(DEFAULT_CONFIG), user)
assert merged["display"]["platforms"]["discord"]["streaming"] is True
# Partial override must not wipe the sibling telegram default.
assert merged["display"]["platforms"]["telegram"]["streaming"] is True
def test_dashboard_schema_exposes_per_platform_streaming():
"""Because the web settings schema is built from DEFAULT_CONFIG, the
per-platform streaming toggles surface in the dashboard automatically."""
import pytest
pytest.importorskip("fastapi") # web_server requires fastapi/uvicorn
from hermes_cli.web_server import CONFIG_SCHEMA
assert "display.platforms.telegram.streaming" in CONFIG_SCHEMA
assert "display.platforms.discord.streaming" in CONFIG_SCHEMA
assert CONFIG_SCHEMA["display.platforms.discord.streaming"]["type"] == "boolean"
# Global streaming controls are exposed too.
assert "streaming.enabled" in CONFIG_SCHEMA
assert "streaming.transport" in CONFIG_SCHEMA
+182
View File
@@ -0,0 +1,182 @@
"""Structured stream-event protocol + dispatcher behavior.
Covers the agentgateway delivery contract introduced to decouple *what
happened* (typed events) from *how it's delivered* (adapter decides). The
default BasePlatformAdapter rendering must reproduce today's behavior exactly;
an adapter may override format_tool_event to eat tool chrome on platforms that
can't render it.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from gateway.stream_dispatch import GatewayEventDispatcher
from gateway.stream_events import (
Commentary,
GatewayNotice,
LongToolHint,
MessageChunk,
MessageStop,
ToolCallChunk,
ToolCallFinished,
)
def _base_adapter():
"""A real BasePlatformAdapter instance (abstractmethods cleared) so we
exercise the genuine default render hooks, not a mock."""
from gateway.platforms.base import BasePlatformAdapter
Concrete = type("Concrete", (BasePlatformAdapter,), {})
Concrete.__abstractmethods__ = frozenset()
return Concrete.__new__(Concrete)
class _FakeSink:
def __init__(self):
self.deltas = []
self.commentary = []
self.segment_breaks = 0
def on_delta(self, text):
self.deltas.append(text)
def on_commentary(self, text):
self.commentary.append(text)
def on_segment_break(self):
self.segment_breaks += 1
# ── Message events → sink ────────────────────────────────────────────────────
def test_message_chunk_flows_to_sink_on_delta():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(MessageChunk("hello "))
d.dispatch(MessageChunk("world"))
assert sink.deltas == ["hello ", "world"]
def test_intermediate_message_stop_breaks_segment_but_final_does_not():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(MessageStop(final=False))
d.dispatch(MessageStop(final=True))
assert sink.segment_breaks == 1 # only the non-final stop breaks
def test_commentary_flows_to_sink():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(Commentary("I'll inspect the repo first."))
assert sink.commentary == ["I'll inspect the repo first."]
def test_message_events_dropped_when_no_sink():
# streaming disabled → no sink → message events are no-ops, no crash.
d = GatewayEventDispatcher(_base_adapter(), sink=None)
d.dispatch(MessageChunk("x")) # must not raise
# ── Tool events → progress queue, formatted by adapter ───────────────────────
def test_tool_call_chunk_renders_default_chrome():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls -la"))
assert len(lines) == 1
assert "terminal" in lines[0]
assert "ls -la" in lines[0]
def test_tool_preview_truncated_to_cap():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all", preview_max_len=10,
)
d.dispatch(ToolCallChunk(tool_name="x", preview="0123456789ABCDEF"))
# capped at 10 → 7 chars + "..." (then wrapped in quotes by the renderer)
assert '"0123456..."' in lines[0]
assert "89ABCDEF" not in lines[0]
def test_new_mode_dedups_same_tool():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="new",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="a"))
d.dispatch(ToolCallChunk(tool_name="terminal", preview="b")) # deduped
d.dispatch(ToolCallChunk(tool_name="read_file", preview="c"))
assert len(lines) == 2 # terminal once, read_file once
def test_off_mode_emits_nothing():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="off",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
assert lines == []
def test_adapter_can_eat_tool_chrome():
"""An adapter that returns None from format_tool_event drops the event —
the 'iMessage can't render tool chrome' case."""
adapter = _base_adapter()
adapter.format_tool_event = lambda event, **kw: None # eat everything
lines = []
d = GatewayEventDispatcher(
adapter, _FakeSink(), enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
assert lines == [] # eaten
def test_tool_finished_emits_no_chrome():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallFinished(tool_name="terminal", duration=2.0, ok=True))
assert lines == []
# ── Control events → gateway-owned hooks ─────────────────────────────────────
def test_long_tool_hint_routes_to_hook():
seen = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(), on_long_tool=seen.append,
)
d.dispatch(LongToolHint(tool_name="terminal", duration=45.0))
assert len(seen) == 1
assert seen[0].tool_name == "terminal"
def test_gateway_notice_routes_to_hook():
seen = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(), on_notice=seen.append,
)
d.dispatch(GatewayNotice(kind="restart", text="Gateway restarted"))
assert seen[0].kind == "restart"
def test_dispatch_swallows_render_errors():
"""A render error must never propagate into the agent worker thread."""
adapter = _base_adapter()
def _boom(event, sink):
raise RuntimeError("render blew up")
adapter.render_message_event = _boom
d = GatewayEventDispatcher(adapter, _FakeSink())
d.dispatch(MessageChunk("x")) # must not raise
@@ -0,0 +1,114 @@
"""TelegramAdapter.send_draft MarkdownV2 formatting parity.
Bot API 9.5 ``sendMessageDraft`` powers the animated streaming preview in
DMs. The regular ``send`` path renders with MarkdownV2, so the draft must
too otherwise the live preview streams as raw text and the final
``sendMessage`` snaps into formatted output, producing a jarring visual
shift at the end of the response (reported by an external user, May 2026).
These tests pin:
1. The happy path passes ``parse_mode=MARKDOWN_V2`` with format_message'd
text (formatting parity with the final message).
2. A MarkdownV2 BadRequest triggers a single plain-text retry rather than
killing draft streaming for the whole response.
3. A non-BadRequest failure propagates so the caller falls back to edit.
"""
import sys
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import PlatformConfig
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
mod = MagicMock()
mod.error.NetworkError = type("NetworkError", (OSError,), {})
mod.error.TimedOut = type("TimedOut", (OSError,), {})
mod.error.BadRequest = type("BadRequest", (Exception,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, mod)
sys.modules.setdefault("telegram.error", mod.error)
_ensure_telegram_mock()
from gateway.platforms import telegram as tg_mod # noqa: E402
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
def _make_adapter() -> TelegramAdapter:
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
adapter._bot = MagicMock()
adapter._bot.send_message_draft = AsyncMock(return_value=True)
return adapter
@pytest.mark.asyncio
async def test_send_draft_passes_markdownv2_parse_mode():
"""Happy path: draft is sent with parse_mode set and format_message'd text."""
adapter = _make_adapter()
# Make format_message observable and deterministic.
adapter.format_message = lambda c: f"FMT::{c}"
result = await adapter.send_draft("123", 7, "**bold** body")
assert result.success is True
adapter._bot.send_message_draft.assert_awaited_once()
kwargs = adapter._bot.send_message_draft.await_args.kwargs
assert kwargs["text"] == "FMT::**bold** body"
assert kwargs["parse_mode"] is tg_mod.ParseMode.MARKDOWN_V2
assert kwargs["chat_id"] == 123
assert kwargs["draft_id"] == 7
@pytest.mark.asyncio
async def test_send_draft_falls_back_to_plain_text_on_markdownv2_error():
"""A MarkdownV2 BadRequest retries once as plain text (no parse_mode),
instead of aborting draft streaming for the whole response."""
adapter = _make_adapter()
adapter.format_message = lambda content: f"FMT::{content}"
# Resolve the BadRequest type the adapter checks via _is_bad_request_error.
from telegram.error import BadRequest # type: ignore
calls = []
async def _draft(**kwargs):
calls.append(kwargs)
if "parse_mode" in kwargs:
raise BadRequest("can't parse entities")
return True
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
result = await adapter.send_draft("123", 9, "weird _text")
assert result.success is True
# First attempt: MarkdownV2; second attempt: plain text, no parse_mode.
assert len(calls) == 2
assert "parse_mode" in calls[0]
assert "parse_mode" not in calls[1]
assert calls[1]["text"] == "weird _text" # raw, unformatted
@pytest.mark.asyncio
async def test_send_draft_non_badrequest_propagates_without_retry():
"""A non-BadRequest failure (e.g. drafts not allowed) returns failure
immediately so the caller falls back to the edit transport."""
adapter = _make_adapter()
adapter.format_message = lambda c: f"FMT::{c}"
calls = []
async def _draft(**kwargs):
calls.append(kwargs)
raise RuntimeError("drafts disabled for this chat")
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
result = await adapter.send_draft("123", 11, "hi")
assert result.success is False
assert len(calls) == 1 # no plain-text retry on non-BadRequest
+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):
@@ -192,6 +192,95 @@ class TestApi401Envelope:
assert "next=" not in body["login_url"]
class TestTransparentRefreshOnAccessTokenEviction:
"""Regression: an expired access token whose cookie the browser has
ALREADY EVICTED must still transparently refresh via the RT cookie
not bounce to /login.
This is the common-path expiry bug, not an edge case. The access-token
cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
the browser deletes ``hermes_session_at`` the instant the token lapses,
while ``hermes_session_rt`` lives for 30 days. From that moment the
browser sends ONLY the refresh-token cookie. The original gate bailed at
``if not at: return _unauth_response(...)`` bouncing the user to
/login on every single expiry despite holding a perfectly good refresh
token, defeating the entire transparent-refresh feature. The fix lets a
request carrying only the RT flow into the refresh path.
Discrimination: under the pre-fix code, scenario 1 (AT cookie absent,
RT present) returned 401/302 to login with NO rotated cookies and NO
REFRESH_SUCCESS the refresh code never ran. With the fix it returns
200 and rotates both cookies.
"""
def _build_rt_only_app(self):
"""Gate over the real app with a Stub provider whose RT is live
(default_ttl>0 so refresh succeeds). Mint a valid signed RT
directly (the stub's refresh_session only checks the RT's
signature + exp), then send ONLY that RT cookie.
"""
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
clear_providers()
provider = StubAuthProvider(default_ttl=900)
register_provider(provider)
valid_rt = _sign(
{"sub": "stub-user-1", "kind": "refresh", "exp": int(_t.time()) + 30 * 86400}
)
return provider, valid_rt
def test_at_evicted_rt_present_refreshes_transparently(self, gated_app):
provider, valid_rt = self._build_rt_only_app()
# Browser sends ONLY the RT cookie — the AT cookie has aged out.
gated_app.cookies.clear()
gated_app.cookies.set(SESSION_RT_COOKIE, valid_rt)
r = gated_app.get("/api/sessions", follow_redirects=False)
# Transparent refresh — request served, NOT bounced.
assert r.status_code == 200, (
f"expected 200 (transparent refresh) got {r.status_code} "
f"— the AT-evicted/RT-present case bounced to login"
)
# Both cookies rotated onto the response.
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(SESSION_AT_COOKIE) or f"-{SESSION_AT_COOKIE}" in c
for c in set_cookies
), f"no rotated AT cookie in {set_cookies!r}"
assert any(
c.startswith(SESSION_RT_COOKIE) or f"-{SESSION_RT_COOKIE}" in c
for c in set_cookies
), f"no rotated RT cookie in {set_cookies!r}"
def test_no_cookies_at_all_still_bounces(self, gated_app):
"""Guard the fix didn't over-reach: a request with NEITHER cookie
must still 401 to login (nothing to verify or refresh)."""
self._build_rt_only_app()
gated_app.cookies.clear()
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "unauthenticated"
def test_dead_rt_only_bounces_to_login(self, gated_app):
"""An RT-only request whose RT is dead/expired must bounce (the
refresh raises RefreshExpiredError clear + relogin), not 500."""
clear_providers()
# default_ttl=0 → the stub treats the minted RT as born-expired,
# so refresh_session raises RefreshExpiredError.
provider = StubAuthProvider(default_ttl=0)
register_provider(provider)
gated_app.cookies.clear()
# A syntactically-real but expired RT (signed with exp<=now).
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
dead_rt = _sign({"sub": "u", "kind": "refresh", "exp": int(_t.time()) - 1})
gated_app.cookies.set(SESSION_RT_COOKIE, dead_rt)
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "session_expired"
class TestHtmlRedirectNext:
def test_deep_html_path_redirects_with_next(self, gated_app):
r = gated_app.get("/sessions", follow_redirects=False)

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