Commit graph

219 commits

Author SHA1 Message Date
Teknium
97524344ad
feat(desktop): run tool backend post-setup installs from the GUI (#40559)
Complete the desktop app's tool-backend configuration so it fully
mirrors `hermes tools`. The toolset config panel already did
enable/disable, provider selection, and API-key save/reveal/clear — the
one remaining gap was post-setup install hooks, which previously just
told the user to run the CLI.

Now a provider that declares a post_setup hook (browser Chromium,
Camofox, cua-driver, KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI)
renders a 'Run setup' button that spawns the install via the
`POST /api/tools/toolsets/{name}/post-setup` endpoint and tails the
log inline, feeding the desktop activity rail — mirroring
command-center's runSystemAction poll loop. On completion the panel
refreshes so a now-installed backend reports itself ready.

- hermes.ts: runToolsetPostSetup(name, key) -> profile-scoped POST.
- toolset-config-panel.tsx: PostSetupRunner sub-component (Run setup
  button + inline live log + activity-rail upsert + unmount guard),
  replacing the CLI-only placeholder.
- i18n: replace the orphaned `toolsets.postSetup` (CLI redirect) string
  with proper post-setup UI keys (hint / run / running / starting /
  complete / error / failed) across en, ja, zh, zh-hant + types.
- test: post-setup run+poll+log-tail coverage; mock additions for
  runToolsetPostSetup/getActionStatus/activity store.

Works against local AND remote backends: all calls route through the
desktop's single `hermes:api` IPC handler to connection.baseUrl, so a
connected remote configures the remote host's tools (keys -> remote
.env, install runs on the remote). Relies on the post-setup endpoint +
'hermes tools post-setup' CLI shipped in #40418.

Verification: tsc -b clean (all 5 locales), eslint clean (the lone
exhaustive-deps warning is pre-existing on origin/main), vitest 4/5
(new post-setup test passes; the failing 'saves an API key' test fails
identically on origin/main — pre-existing EnvVarActionsMenu drift).
2026-06-06 18:35:02 -07:00
Teknium
5b43bf7d02
feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) (#40355)
* feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI)

Adds a GUI-only uninstall path so people can remove the desktop Chat GUI
while keeping the Hermes agent + their config/sessions/.env, and surfaces
the three CLI uninstall modes inside the desktop app's Settings → About.

CLI:
- New hermes_cli/gui_uninstall.py: cross-platform discovery + removal of the
  desktop GUI's artifacts (source-built dist/release/node_modules + build
  stamp, the packaged app bundle, and the Electron userData dir) on Linux,
  macOS, and Windows. Never touches the agent source, venv, or user data.
- `hermes uninstall --gui` removes only the Chat GUI; `--gui-summary` prints a
  JSON install snapshot (used by the desktop UI to gate options + detect a
  missing agent for a future lite client).
- `hermes uninstall --yes` / `--full --yes` now run non-interactively, sharing
  the destructive sequence via a new _perform_uninstall() helper. The keep-data
  and full flows also sweep the GUI artifacts.

Desktop:
- electron/desktop-uninstall.cjs: pure helpers mapping each mode (gui/lite/full)
  to CLI flags, resolving the running app bundle per OS, and building the
  detached cleanup script that waits for the app to exit, runs the Python
  uninstall, and removes the bundle.
- IPC hermes:uninstall:summary / :run, preload bridge, and types.
- Settings → About "Danger zone" with the three options; agent-removing
  options hide when no local agent is detected.

Tests: tests/hermes_cli/test_gui_uninstall.py (22 pass with the existing
uninstall tests), electron/desktop-uninstall.test.cjs (17 pass, wired into
test:desktop:platforms). Docs: desktop.md "Uninstalling" + cli-commands.md.

* fix(desktop): tear down backend process tree before GUI uninstall (Windows lock safety)

The desktop uninstall cleanup script waited only on the desktop app's own
PID, but a backend grandchild (gateway / pty terminal / hermes REPL) can
outlive it and keep hermes.exe + venv files mandatory-locked on Windows —
making the script's rmdir half-fail and leaving a partial install, the same
failure class as the self-update path's #37532.

- main.cjs: runDesktopUninstall now awaits releaseBackendLock() before
  spawning the cleanup script — tree-kills every backend PID the desktop owns
  (primary + pool) via taskkill /T /F and polls the venv shim until unlocked.
  Extracted the shared core out of releaseBackendLockForUpdate so both the
  update hand-off and the uninstaller use the identical, incident-hardened
  teardown. No-op on macOS/Linux (no mandatory locks).
- desktop-uninstall.cjs: Windows cleanup script removes the bundle via a
  bounded rmdir retry loop (10x, 1s) instead of a single rmdir, since Windows
  releases directory handles lazily even after the holding process exits.
- Dropped a fragile tasklist|findstr reap-by-path attempt; the Electron-side
  tree-kill-by-PID is the reliable mechanism.

Tests: desktop-uninstall.test.cjs updated for the retry-loop output (17 pass).

* fix(desktop): address review on GUI uninstall (venv self-delete, gates, wait-loop)

Resolves @OutThisLife's review on #40355:

1. full mode now gated on agent presence (needsAgent: true). It removes the
   agent + user data, so on a lite client with no local agent it's hidden
   like lite — no more offering to remove an agent that isn't there.

2. (Finding 3, the real bug) lite/full no longer rmtree the venv from the
   venv's OWN python. On Windows a running python.exe is mandatory-locked, so
   that half-fails. New lightweight 'python -m hermes_cli.uninstall --mode X'
   entrypoint (stdlib-only imports) lets the desktop run agent-removing modes
   under the SYSTEM python (findSystemPython) with PYTHONPATH=<agentRoot>, so
   import hermes_cli resolves from source while the venv is torn down. Falls
   back to venv python + logs when no system python (gui-only unaffected).

3. Windows wait-loop is now bounded (60 tries, matching POSIX) and matches the
   PID as a whole space-delimited token via findstr (no substring 99->990
   trap, no redundant bare find). set HERMES_HOME/PID/PYTHONPATH now quoted.

4. Renamed the misleading 'returns null for dev run' test — the dev-run safety
   is shouldRemoveAppBundle(isPackaged=false), which the test now asserts.

Docs: note that --gui on a source checkout also sweeps node_modules/build
output. Tests: 18 python + 19 desktop pass.
2026-06-06 18:22:38 -07:00
brooklyn!
d165933c56
docs(desktop): add DESIGN.md design-system guide + close two consistency gaps (#40823)
Codify the desktop overlay/design conventions in apps/desktop/DESIGN.md:
surfaces & elevation (shadow-nous + --stroke-nous), stroke/color tokens, the
single Button (variants/sizes, no per-call overrides), shared form controls
(controlVariants / SearchField / SegmentedControl / Switch), flat layout
(PAGE_INSET_X, OverlaySplitLayout, ListRow, no card-in-card), feedback states
(Loader / ErrorState / LogView / EmptyState), BrandMark, motion, i18n, and the
nanostore state model. Ends with a pre-merge checklist.

Two fixes so the doc isn't aspirational:
- brand-mark: rounded-md + overflow-hidden (doc says "softly rounded")
- i18n ja/zh/zh-hant: mirror en's "Begin" + drop trailing period on
  connectedProvider (doc says update all locales together)
2026-06-06 22:13:17 +00:00
brooklyn!
f033b7dbfb
feat(desktop): unified overlay design system, BrandMark & onboarding redesign (#40708)
* fix(desktop): unify dialog/overlay buttons on shared Button component

Replace raw <button> action/text controls across the modal layer (boot
failure, install, update, onboarding, clarify, model-visibility,
notifications, gateway menu) with the shared Button + its variants
(text / ghost / icon-xs). Drops the bespoke square-cornered styling so
every dialog matches the app's slightly-rounded button system, and
swaps clarify-tool's hardcoded "Skip" for the existing i18n string.

* feat(desktop): add dev-only dialog gallery for auditing overlays

A code-split, DEV-gated harness (toggle ⌘/Ctrl+Alt+Shift+D) that triggers
every dialog/overlay so their buttons can be eyeballed in one place:
store-driven overlays (boot failure, updates, notifications, sudo/secret)
plus in-place dialogs (confirm, profile create/rename, attach-url, model
picker/visibility, clarify, tool approval). Never ships to production.

* fix(desktop): use Ctrl+Shift+D for dialog gallery (mac-friendly)

The Cmd/Ctrl+Alt+Shift+D chord is impractical on macOS (Option mangles
the keypress). Ctrl+Shift+D is the same chord on every platform and uses
neither Cmd nor Option.

* fix(desktop): stop overriding button icon size to size-4

Action buttons hardcoded size-4 icons, overriding the Button component's
built-in size-3.5. That extra 2px is why boot-failure / onboarding / gateway
buttons looked chunkier than the settings "Apply" (size-3.5 spinner) despite
being the same component+size. Drop the overrides so icons inherit 3.5.

* feat(desktop): add BrandMark, use it in the updates overlay hero

New BrandMark renders the white logo.png on a hardcoded brand-blue tile
(#0000F2 light / #222 dark), replacing the generic Sparkles hero glyph in
the "update available" overlay. Trying it here first to iterate on the look.

NOTE: apps/desktop/public/logo.png is currently a 1x1 placeholder — the tile
renders now; the glyph appears once the real white logo art is dropped in.

* feat(desktop): add real logo.png asset, render it white in BrandMark

logo.png is blue line-art on transparent, so force it white via filter to
read on both the brand-blue (#0000F2) and near-black (#222) tiles. Bump the
glyph to 62% of the tile for the portrait aspect.

* fix(desktop): BrandMark renders logo as-is, no light bg/radius/padding

Drop the white filter, the hardcoded light-mode blue tile, the radius, and
the inner padding. Logo now fills the tile over a transparent surface in
light mode; dark keeps the #222 tile.

* fix(desktop): bump updates-overlay BrandMark to size-16

* feat(desktop): use downscaled karb.webp in BrandMark

Swap the BrandMark glyph to karb.webp, downscaled from 1129x1418/888KB to
254x320/81KB for the hero badge.

* feat(desktop): use nous-girl mark in BrandMark, invert in dark

Key the white background to transparent so only the black line-art remains
(384px/20KB webp). Light mode shows black art; dark mode flips it white via
dark:invert on the #222 tile. Drop the now-unused karb.webp and logo.png.

* fix(desktop): BrandMark uses nous-girl as-is (no transparent/invert)

The dark-mode invert read as a creepy negative. Use the opaque black-on-white
mark unchanged in both themes; drop the white-key, dark:invert, and #222 tile.

* fix(desktop): give BrandMark an explicit white bg tile

* fix(desktop): use nous-girl.jpg directly in BrandMark

* perf(desktop): downscale nous-girl.jpg to 256x256 (466KB -> 19KB)

* style(desktop): bump nous light --theme-secondary to 14% blue

* fix(desktop): outline button is transparent, not chrome-filled

The outline variant used bg-background (the chrome color), so on cards/overlays
with a different surface it rendered as an odd gray-blue fill (visible on the
boot overlay's Repair install / Use local gateway). Make it bg-transparent so
it inherits the surface like a real outline. Reverts the unrelated
--theme-secondary tweak.

* fix(desktop): clean outline button — thin border, no shadow/fill

Drop shadow-xs and the resting fills (light chrome bg, dark bg-input/30) so
outline is just a thin clean border with a subtle hover, in both themes.

* fix(desktop): stop forcing tertiary bg on outline buttons

A global [data-variant='outline'] rule set background: var(--ui-bg-tertiary),
which (attribute-selector specificity) overrode the cva bg-transparent — so
outline buttons always showed the pale tertiary fill on cards/overlays
regardless of the variant classes. Scope that fill to secondary only; outline
is now a true transparent border.

* style(desktop): unified overlay design system + restore #38631 flat-UI

Overlays/dialogs/toasts share a custom shadow-nous (downward-weighted) and
--stroke-nous hairline instead of hard borders: boot-failure, install,
notifications, model-picker, onboarding, prompt-overlays, updates, Dialog.

- button: outline is a 1px inset ring (no fill/shadow); chrome lives in Button
- BrandMark: 256px nous-girl mark replaces sparkle glyphs (updates/onboarding/about)
- onboarding: conditional header, lemniscate-bloom loaders, OTP device-code boxes,
  NOUS CONNECTED hero (ascii decode) + cuneiform easter egg, "Begin" matrix exit
- shared LogView + ErrorState; math/ascii loaders over "Loading..." text
- appearance-settings flattened to SegmentedControl/ListRow; keybind-panel on
  shadow-nous + text-variant reset
- restore flat-UI clobbered by #38631's stale-squash (4a1907bd1): command-center,
  profiles, skills, messaging, cron de-boxed; shared SearchField + PAGE_INSET_X;
  profiles back on OverlaySplitLayout; skills tabs+search one row, no row dividers

* refactor(desktop): clean pass — drop dead code, dedupe, fix stale docs

- log-view: drop unused `bare` prop + forwardRef (no caller uses ref)
- install-overlay: drop `stateOverride` (only the removed dev gallery used it)
- profiles: ProfilesViewProps down to { onClose } (drop vestigial section/titlebar)
- onboarding: hoist shared PROVIDER_ROW_CLASS (was duplicated 2x)
- brand-mark / error-state: tighten comments, fix stale AlertCircle reference
2026-06-06 16:32:47 -05:00
Brooklyn Nicholson
146e77684b fix(desktop): bound desktop.log via cascade rotation + reclaim oversized logs
Supersedes the single-.1 rotation from the prior commit, which only bounded
FUTURE growth: rotating a pre-existing oversized desktop.log just renamed the
monster to .1 (no disk reclaimed) and left it stranded until a second rotation
cycle that a now-healthy app may never reach. The ~326 GB file that motivated
this PR would therefore persist as desktop.log.1 after the user updated.

Two changes bring desktop.log in line with the Python-side logs
(hermes_logging.py RotatingFileHandler, maxBytes x backupCount):

1. Cascade rotation: live -> .1 -> .2 -> .3, dropping the oldest. Steady-state
   usage is bounded at ~(backupCount + 1) x cap regardless of loop intensity,
   instead of the old ~2x with a single backup.

2. Pathological-size discard: a file past 4x the cap is a boot-loop artifact
   with no diagnostic value — delete it (and any equally poisoned backups)
   outright instead of relocating the disk-exhaustion problem into a sibling.
   This is what lets an updated app self-heal a disk a stale build filled,
   on the very next launch, rather than one rotation cycle later.

Behavior verified against a real filesystem in a temp dir: under cap -> no
rotation; normal overflow -> live becomes .1; repeated overflow keeps exactly
backupCount backups (no .4) with total bounded; a pathological live file plus
poisoned backups are all reclaimed. node --check passes.

Co-authored-by: The Garden <chilltulpa@gmail.com>
2026-06-06 12:43:28 -05:00
The Garden
abbf050241 fix(desktop): cap desktop.log size to prevent unbounded growth
desktop.log is an append-only forensic log written via appendFileSync /
fs.promises.appendFile with no rotation. When the backend enters a boot
loop — e.g. the version-skew crash where an old app shell spawns
`dashboard --tui`, argparse exits(2) instantly, and the renderer keeps
retrying — the full bootstrap transcript plus repeated stack traces are
appended on every attempt. In the wild this drove a single desktop.log to
~326 GB, exhausting the disk and breaking `hermes update`/install (git
index.lock, venv rebuild, and npm all need scratch space).

Rotate to a single .1 sibling once the live file crosses a 10 MB cap, so
total on-disk usage stays ~2x the cap while preserving the most recent
transcript for diagnostics. The size check runs before each append in both
the sync (shutdown) and async (steady-state) flush paths. All filesystem
ops stay inside try/catch so logging can never block startup/shutdown or
crash the shell — consistent with the existing append error handling.

Paired with the CLI --tui back-compat guard in this PR: the guard stops the
crash loop from starting, and this stops a crash loop (from any cause) from
ever filling the disk.
2026-06-06 12:43:28 -05:00
brooklyn!
e3ae035921
Merge pull request #40660 from NousResearch/bb/keybinds
feat(desktop): rebindable keyboard shortcuts panel
2026-06-06 12:00:08 -05:00
Brooklyn Nicholson
e9b8dd236c fix(desktop): default-profile hotkey to two-key cmd+d mnemonic
⌥⌘0 was awkward to press. ⌘D ("D for Default") is two keys, unreserved,
and not used elsewhere in the map.
2026-06-06 11:55:15 -05:00
Brooklyn Nicholson
06ecc5535c fix(desktop): rebind default-profile hotkey off macOS-reserved cmd+`
macOS reserves cmd+` for window cycling, so the keydown never reached the
renderer and profile.default never fired. Move it to ⌥⌘0 — the "0 slot" of
the ⌘⌥-digit profile range — which is unreserved and fits the scheme.
2026-06-06 11:54:48 -05:00
Brooklyn Nicholson
74c8f51e95 fix(desktop): match file-browser default width to sessions sidebar
Both rails now open at SIDEBAR_DEFAULT_WIDTH so a fresh window has
equal-width sidebars instead of the old 237px vs 17rem mismatch.
2026-06-06 11:51:45 -05:00
Brooklyn Nicholson
182092c5fd feat(desktop): default swap-panes to cmd+backslash 2026-06-06 11:48:39 -05:00
Brooklyn Nicholson
021ea2a21b fix(desktop): only show keybind reset when changed from default 2026-06-06 11:48:16 -05:00
Brooklyn Nicholson
258984fcb9 feat(desktop): broaden hotkey coverage + fold in stray shortcuts
Add rebindable actions for the high-frequency gaps: focus composer, open
model picker, next/prev session, search sessions (⌘⇧F), show files/
terminal tab, and nav→artifacts. Reconcile the duplicate Shift+N new-
session listener into session.new's defaults, and surface the remaining
context-local shortcuts (⌘↵ steer, ⌘L terminal selection, ⌘W close
preview) as read-only rows so the panel is the honest source of truth.
2026-06-06 11:47:33 -05:00
Brooklyn Nicholson
5e2b83a8ad feat(desktop): rebindable keyboard shortcuts panel
Add a central keybind registry + nanostore so desktop hotkeys are
discoverable and user-rebindable. A titlebar ⌨ button (and ⌘/) opens a
collapsible map grouped by Composer (read-only) / Profiles / Session /
Navigation / View; click any chip to capture a new combo. Overrides
persist to localStorage as a delta against shipped defaults, so future
default changes aren't shadowed by a stored snapshot.

Migrates the previously scattered inline listeners (palette, command
center, new session, sidebar, theme) into the registry, and adds profile
switch/cycle/create + default-profile hotkeys.
2026-06-06 11:41:57 -05:00
Teknium
e8c837c921
feat(desktop): surface every provider + models from hermes model in the GUI menus (#40563)
* feat(desktop): surface every provider + models from `hermes model` in the GUI

The desktop GUI's model/provider choices were starved relative to the
`hermes model` CLI. Onboarding listed ~8 providers, Settings → Model only
showed authenticated ones, because the global `/api/model/options` endpoint
called build_models_payload() without the full-universe flags the TUI's
model.options JSON-RPC already used.

- web_server.py: `/api/model/options` now passes include_unconfigured +
  picker_hints + canonical_order (matching the TUI handler), so every GUI
  surface fed by it sees all 37 canonical providers with auth hints.
- Settings → Model: provider dropdown lists every provider; picking an
  unconfigured api_key provider shows an inline 'paste key → Activate' flow
  (auto-selects the recommended default); OAuth/external route to onboarding.
- Onboarding: the API-key form is now driven by the full provider catalog
  (curated five first, then the rest), not a hand-maintained list of five.
- types/hermes.ts: ModelOptionProvider gains authenticated/auth_type/key_env.
- Tests: model-settings covers the full-universe list + inline activation;
  fixed a pre-existing stale assertion (nous / hermes-4 was never rendered).

* feat(desktop): /model in GUI chat opens the model picker instead of a dead-end notice

Typing /model in a desktop chat session printed "/model uses the desktop
model picker instead of a slash command" and did nothing — it never opened
the picker. (The slash worker can't render the prompt_toolkit modal /model
opens in the CLI, so the desktop just showed the unavailable-notice.)

- use-prompt-actions.ts: intercept /model client-side. No args → open the
  desktop model picker overlay (setModelPickerOpen) — the same full
  provider+model picker as the status-bar button. With args (/model <name>
  [--provider ...]) → run the switch directly via slash.exec so power users
  can still type it.
- desktop-slash-commands.ts: export isModelPickerCommand() so the hook can
  detect picker-owned commands without duplicating the PICKER_OWNED_COMMANDS set.
- Test: covers isModelPickerCommand for /model (+ args) vs non-picker commands.

* fix(desktop): make onboarding provider lists scrollable + clean up card styling

The full-catalog onboarding picker could overflow the modal with no way to
scroll — the OAuth provider list and the api-key grid both grew past the
viewport, hiding the key input and the bottom action row (overflow-hidden card,
no scroll container).

- Scope a `max-h-[60dvh] overflow-y-auto` region to just the provider list /
  api-key card grid; the "other providers" disclosure, key input, and action
  row stay pinned and reachable.
- Inner `p-1` so card borders / focus rings aren't clipped by the scroll viewport.
- Flatter card styling: drop the persistent border, the redundant selected-state
  checkmark, and the modal shadow — selection now reads from the ring alone (the
  muted "already configured" check stays).
- Remove the " — set up" suffix from the Settings → Model provider dropdown; the
  inline setup flow already signals unconfigured providers.

* fix(desktop): identify api-key onboarding cards by env var, not id

Selecting "Google Gemini" also highlighted "Google AI Studio": the curated
catalog and the backend-derived providers can collide on `id` (a provider slug
can equal a curated id like `gemini`), so `option.id === o.id` matched two
cards at once. Key selection (and the React key + snap-back effect) on `envKey`
instead, which the catalog dedups and is therefore unique per card.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-06-06 16:31:34 +00:00
Brooklyn Nicholson
3a46262c7c Merge remote-tracking branch 'origin/main' into bb/remove-composer-message-shadows
# Conflicts:
#	apps/desktop/src/components/assistant-ui/tool-fallback.tsx
2026-06-06 10:47:42 -05:00
Brooklyn Nicholson
9d31577590 Tighten conversation rhythm, flatten the tool list, and smooth streaming text
Conversation rhythm:
- Single `--paragraph-gap` knob drives paragraph spacing both inside a
  markdown block and between consecutive prose parts, out-specifying Tailwind
  Typography's prose margins. Code cards carry the same gap themselves so it
  holds at any Streamdown nesting depth.
- Two-tier vertical rhythm: `--turn-block-gap` separates scaffolding (tools /
  thinking) from the reply; `--tool-row-gap` keeps a tool run tight.
- Drop the prose indent so prose, tools, todos, and thinking share one left
  edge. `---` renders as quiet spacing, not a heavy rule.

Flat tool list:
- Tools always render as a standalone-row stack, never a "Tool actions · N
  steps" group. assistant-ui slices the tool range unstably (interleaved live
  vs. reconstructed-consecutive when settled), so grouping reshuffled the whole
  turn the instant it settled. Flat rows are pixel-identical either way.
- Inline approvals can no longer be buried in a collapsed group body.
- Remove the now-dead grouping helpers from tool-fallback-model.

Empty thinking:
- Suppress reasoning disclosures with no visible text (encrypted / spinner-
  coerced reasoning) instead of leaving an empty "Thinking" header.
- Tail stall indicator returns "thinking" when a running turn goes quiet.

Streaming cadence:
- Smooth character-reveal decouples visible cadence from bursty arrival.
- Flush queued text deltas before applying tool events so a tool row can't
  jump ahead of its preceding text.
- Disable Nagle on the GUI WebSocket so per-token frames aren't coalesced.

Polish: clarify/patch/vision_analyze tool meta, queue-panel + diff-lines
spacing, sticky human bubble expands on focus (not hover).
2026-06-06 10:45:31 -05:00
Jim Liu 宝玉
1c2189839d Refactor desktop settings i18n keys to camelCase 2026-06-06 07:51:44 -07:00
Jim Liu 宝玉
c24abf5b32 Add missing Chinese desktop i18n translations 2026-06-06 07:51:44 -07:00
Jim Liu 宝玉
112a0732c6 Translate missing desktop i18n strings for ja and zh-hant 2026-06-06 07:51:44 -07:00
Jim Liu 宝玉
fbd423b94d feat(desktop): localize desktop chrome
Co-authored-by: Kiro 有点Yes <246816394+sdyckjq-lab@users.noreply.github.com>
2026-06-06 07:51:44 -07:00
Jim Liu 宝玉
812dc6957e Add searchable language picker 2026-06-06 07:51:44 -07:00
Jim Liu 宝玉
b1b89f843e Refactor desktop i18n field copy into nested structures 2026-06-06 07:51:44 -07:00
Jim Liu 宝玉
f18a9dbefc feat: Add desktop language switching for Japanese and Traditional Chinese 2026-06-06 07:51:44 -07:00
Brooklyn Nicholson
6bbc5eefa0 Fix clarify icon alignment and spurious error-red on non-zero exit
- clarify-tool: top-align the help icon (items-start + mt-px) so it sits
  beside the first line of a multi-line question instead of floating
  centered against the whole block.
- tool-fallback: a non-zero exit code alone no longer paints the whole
  terminal/execute_code card red. grep no-match, diff differences, and
  piped commands routinely exit non-zero while producing useful output;
  only flag an error when the command produced no output. Explicit error
  signals (error field, success=false, status=error, isError) still go red.
- Add regression tests covering the exit-code -> status matrix.
2026-06-06 09:23:50 -05:00
Brooklyn Nicholson
40386f33ec Remove drop shadows from composer and user message bubbles
Strip shadow-composer (and its focus/open-state variants) from the
composer surface, composer fallback surface, and the shared user-bubble
base class. Also drop the !important box-shadow override on
[data-slot=composer-surface] that re-applied the shadow regardless of
the utility class, so the flatter look actually takes effect.
2026-06-06 09:18:54 -05:00
Teknium
b91aade176
feat(desktop): warn when main-model switch leaves auxiliary tasks pinned to another provider (#40286)
Switching the main model never touches auxiliary slot pins (they're
independent, sticky per-task overrides). A user who switches main away
from a now-unpaid provider keeps paying 402s on every background aux call
until they manually reset those pins — silently, with no UI signal.

- /api/model/set scope:'main' now returns stale_aux: slots still pinned
  to a provider different from the new main (additive field).
- Desktop Model Settings shows a switch-time notice after Apply AND a
  persistent banner when any loaded aux slot mismatches the main provider,
  both wired to the existing 'Reset all to main' action.
- Never auto-clears pins — a dedicated cheaper aux model is a legitimate
  config; surface-and-offer instead of nuking.
- Fixes a stale pre-existing assertion in the panel test (main model now
  renders via selectors, not a standalone label).
2026-06-05 23:35:36 -07:00
teknium1
16beab421f fix(desktop): About panel shows live Hermes version, not stale package.json
The native macOS About panel showed the Electron package.json version
(e.g. 0.15.1) while the status bar showed the real Hermes version
(0.16.0). setAboutPanelOptions() set applicationName + copyright but
omitted applicationVersion, so macOS fell back to app.getVersion() =
package.json, which drifts (release.py's desktop lockstep bump didn't
land for 0.16.0).

resolveHermesVersion() already reads the live version from
hermes_cli/__init__.py and was built 'so the desktop About panel shows
the real Hermes version' per its own comment, but was never wired in.

- Seed applicationVersion: resolveHermesVersion() at module load.
- Replace the macOS About menu item's role:'about' with a click handler
  (showAboutPanelFresh) that re-resolves the version on every open, so an
  in-place `hermes update` is reflected without an app restart.
2026-06-05 23:32:16 -07:00
Brooklyn Nicholson
5d4c93afe4 refactor(desktop): hoist single draft.trim() in composer
Compute the trimmed draft once and reuse for hasComposerPayload + canSteer
instead of trimming three times per render.
2026-06-05 21:05:56 -05:00
Brooklyn Nicholson
7cceead273 fix(desktop): render steer note as a codicon, not an emoji
The inline steer note used a  emoji. Emit a structured `steer:<text>`
system note and render it in SystemMessage as a codicon (compass) row —
same style as slash-status output. No emoji in the transcript.
2026-06-05 21:03:05 -05:00
Brooklyn Nicholson
efa53fb3be feat(desktop): reserve Cmd/Ctrl+Enter strictly for steer
Cmd/Ctrl+Enter now steers when there's a steerable draft and is a no-op
otherwise — it never falls through to a send, so the shortcut can't
surprise-send. Plain Enter keeps its role (queue while busy, send when idle).
2026-06-05 21:01:20 -05:00
Brooklyn Nicholson
0f45509daf fix(agent): make mid-turn /steer trusted, not read as injection
A steer rides inside a tool result (the only role-alternation-safe slot
mid-turn), so a bare "User guidance:" line reads as untrusted tool content —
well-behaved models refuse it as suspected prompt injection (observed live:
"I only follow instructions from you directly, not ones injected through
command results").

- Wrap steers in a bounded, self-describing [OUT-OF-BAND USER MESSAGE] marker
  (prompt_builder.format_steer_marker), shared by both drain sites.
- Add STEER_CHANNEL_NOTE to the core system prompt so the model expects this
  exact marker and trusts it as a genuine user message — while still ignoring
  lookalikes buried in tool/web/file output. Static text → byte-stable prompt,
  no prompt-cache regression; gated on the agent having tools.
- Desktop: steer ack is now an inline transcript note ( steered · …) instead
  of a toast.

Marker is intentionally static (not a per-session nonce) to honor the
byte-stable system-prompt caching policy; nonce hardening noted as follow-up.
2026-06-05 20:59:36 -05:00
Brooklyn Nicholson
40aef6af91 feat(desktop): steer the live run from the composer
The desktop app could only queue while busy — `/steer` was in the palette
but had no first-class affordance, so the "nudge the agent mid-turn without
interrupting" lane was effectively unreachable.

Add a steer action to the composer: while busy with a text-only draft, a
steering-wheel button (and Cmd/Ctrl+Enter) injects the text into the live
turn via the `session.steer` RPC — the gateway folds it into the next tool
result so the model reads it on its next iteration. Plain Enter still queues.

steerPrompt returns false when the gateway has no live tool window (or the
RPC errors), and the composer re-queues the words so nothing is lost — the
same safety net as a plain queue.
2026-06-05 20:50:30 -05:00
Brooklyn Nicholson
ce50030634 feat(desktop): integrate arrow history with the message queue
Builds on @naqerl's arrow up/down history (previous commit), making
ArrowUp do the right thing when a queue exists.

ArrowUp/ArrowDown priority:
1. Editing a queued turn → walk older/newer through queued entries,
   saving each edit; ArrowDown past the newest exits and restores the
   pre-edit draft.
2. Empty composer + queued turns → ArrowUp opens the newest queued entry
   for editing (the row's pencil), so Enter saves it back to the queue
   instead of firing a new message — the gap the history nav had alone.
3. Otherwise → sent-message history recall (unchanged).

Also: Esc cancels an in-progress queue edit (else interrupts).

Cleanups on the integrated code: fold the browse-state reset into the
existing session-change effect (drop the duplicate ref+effect); reuse
loadIntoComposer for history recall; sort imports; add curly braces +
the runDrain sessionId dep (lint).
2026-06-05 20:33:53 -05:00
naqerl
f94363d1f0 feat(desktop): arrow up/down to navigate previous user messages 2026-06-05 20:32:29 -05:00
brooklyn!
0cbcc75935
fix(desktop): reliable composer message queue (#40221)
* fix(desktop): make composer message queue reliable

The queue felt 'dumb' because of three real bugs:

1. Drained-after-interrupt sends went silent. cancelRun sets
   interrupted:true and nothing reset it; submitPromptText's optimistic
   seed preserved it, and the message stream drops every delta while
   interrupted. So Send-now-while-busy and any interrupt+drain submitted
   the next turn into a muted session. Fix: a fresh submit is a new turn —
   seed interrupted:false.

2. Back-to-back queue drains stalled. The drain fires on the busy->false
   settle edge, but busyRef (synced from the busy store by a separate
   effect) can still read true on that same edge, so the drained send hit
   the busy guard, returned false, and the entry was never removed. Fix:
   fromQueue sends bypass the busyRef guard (the queue drain lock
   serializes them); the user path keeps the guard.

3. Double-enter-to-interrupt killed single non-queue turns. The hidden
   450ms timer meant a natural double-tap after sending stopped the agent.
   Fix: empty Enter while busy is a no-op; interrupting is explicit —
   Stop button or Esc.

Also: clean stop (no [interrupted] marker), Send-now works while busy
(promote + interrupt + auto-drain), settle on the interrupted completion
path. Adds regression tests and unblocks the prompt-actions suite by
completing its stale @/hermes mock.

* fix(desktop): float the queue panel as an overlay so the chat doesn't resize

The queue list rendered in-flow inside the composer root, so its height
fed --composer-measured-height (the composer rect drives the thread's
bottom padding + last-message clearance). Queuing a message grew that
rect and the whole chat visibly resized.

Anchor the panel out of flow above the composer (absolute bottom-full,
capped at 40vh with internal scroll). It no longer contributes to the
measured height, so the thread layout stays put and the list overlays the
(already faded) chat. Still collapsible via the panel's own
disclosure header.

* fix(desktop): queue panel collapsed by default + shared border with composer

- Default the queue disclosure to collapsed (compact 'N queued' pill)
  instead of expanded.
- Drop the gap and merge the panel into the composer: square bottom
  corners, no bottom border/radius, and overlap down by the Root's pt-2
  (-mb-2) so the panel's borderless bottom lands on the composer surface's
  top border — one continuous bordered shape.

* style(desktop): tighten queue panel padding

* style(desktop): trim queue-ux comments to house style

* style(desktop): drop 'Cursor' references from comments
2026-06-05 20:21:41 -05:00
Gille
0c0a707744
fix(desktop): repair macOS updater helper (#40217) 2026-06-05 20:05:32 -05:00
Brooklyn Nicholson
9c1bb8d2c7 Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update.
2026-06-05 18:05:05 -07:00
teknium1
aa52cd3b57 test(desktop): unmount between IME composition repro cases
The new IME repro test has two it() blocks but the desktop suite registers
no global testing-library auto-cleanup, so the first render() leaked its
editor into the second test and getByTestId('editor') matched two nodes.
Add afterEach(cleanup) so each case renders into a fresh DOM.
2026-06-05 18:05:00 -07:00
xxxigm
da9425bf9b test(desktop): cover IME-composed send-button visibility (Chinese/Japanese/Korean)
DOM repro that drives compositionstart -> input(preedit) -> compositionend with
no trailing input event and asserts the composer payload (send button) becomes
visible for committed CJK/IME input. Regression guard for #39614.
2026-06-05 18:05:00 -07:00
xxxigm
8e629b9f38 fix(desktop): flush committed IME text on compositionend so the send button appears
Typing committed multi-character IME text (e.g. Chinese "你好", and equally
Japanese/Korean or any IME-composed script) left the send button hidden until
an unrelated edit. Input events during composition carry uncommitted preedit
text and are intentionally skipped; the code assumed a trailing input event
after compositionend would deliver the finalized text, but Chromium does not
reliably emit one on Windows IMEs. The committed text therefore never reached
composer state, so `hasComposerPayload` stayed false and the send button stayed
hidden (deleting a char fired a non-composition input that finally synced it).

Flush the live editor text into composer state in onCompositionEnd. Extract the
shared sync into flushEditorToDraft so input and compositionend both update
state.

Fixes #39614
2026-06-05 18:05:00 -07:00
teknium1
be2c64be02 fix(desktop): wire serializeJsonBody into OAuth request path
The salvaged helper exported serializeJsonBody but main.cjs still inline-built
the request body, leaving the export dead and the test decoupled from the real
path. Use it at the fetchJsonViaOauthSession site so the helper's coverage
exercises production body construction. Byte-identical output.
2026-06-05 18:04:45 -07:00
helix4u
b8234e7599 fix(desktop): avoid restricted oauth request header 2026-06-05 18:04:45 -07:00
brooklyn!
6f6eb871d8
fix(gateway): new chats honor their profile in global-remote mode (#39993)
Follow-up to #39921. That PR scoped session.resume + prompt.submit to a
session's profile, but a BRAND-NEW chat (session.create) under a non-launch
profile was still built and persisted against the dashboard's launch profile.
Two visible symptoms in app-global remote mode (one dashboard, many profiles):

  1. "who are you" in profile S replied as the launch (default) profile/agent —
     the agent was built with the launch HERMES_HOME, so config/SOUL/identity
     came from the wrong profile.
  2. "session not found" on later resume — _ensure_session_db_row persisted the
     row into the launch profile's state.db via _get_db(), so the session lived
     in the wrong db, the unified list mis-tagged it (it showed up under BOTH
     profiles), and resume routed to the wrong one.

Fix — carry the owning profile through the create path too:

- session.create accepts an optional `profile`; resolves its home and stores
  `profile_home` on the session (alongside what resume already set).
- _start_agent_build binds that profile's HERMES_HOME while building the agent
  (config/skills/model/identity resolve to it) and hands the agent the profile's
  state.db so turns persist there.
- _ensure_session_db_row writes the row into the profile's state.db, not the
  launch db — fixing the duplicate row + mis-tag + resume 404.
- desktop sends the new-chat profile on session.create.

None/launch profile → unchanged (single-profile and per-profile-remote setups
take the same path). Verified live against a one-dashboard / multi-profile
remote: a new chat under `work` builds as work's agent (correct SOUL identity),
persists ONLY to work's state.db (launch db stays empty), the unified list tags
it `work` exactly once, and it resumes cleanly.

tests/test_tui_gateway_server.py: _make_agent mocks updated for the session_db
param added in #39921's build path.
2026-06-05 17:44:45 +00:00
Jim Liu 宝玉
1d9c3ebae0 feat(desktop): persist i18n language in config 2026-06-05 10:32:26 -07:00
Jim Liu 宝玉
4a1907bd10 feat(desktop): add i18n with Simplified Chinese (zh-Hans) support
Introduce a lightweight React context-based i18n layer for the desktop
app and translate the UI into Simplified Chinese.

- New apps/desktop/src/i18n module: typed Translations interface, en + zh
  locale tables, I18nProvider/useI18n, localStorage-persisted locale
  (defaults to English), and language endonym metadata for the picker.
- Wire I18nProvider at the app root in main.tsx.
- Refactor 24 desktop screens/components to read strings from the `t`
  object instead of hard-coded English.
- Add a unit test for the i18n context.
2026-06-05 10:32:26 -07:00
brooklyn!
02d6bf1c39
fix(desktop+gateway): full multi-profile support over one global-remote dashboard (#39921)
* fix(desktop): cross-profile session history in app-global remote mode

#39894 made remote-profile sessions first-class for PER-PROFILE remote
overrides. But the common setup — Settings → Gateway → "All profiles" → Remote
— writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty
profiles map), which the intercept didn't recognize. Switching to a non-launch
profile then 404'd every session read, so no history showed for it.

In global remote mode a SINGLE backend serves every profile via ?profile= (it
reads each profile's state.db off the remote host's own disk — verified: one
dashboard returns /api/profiles and /api/profiles/sessions?profile=all across
all profiles). The fix: when no per-profile override matches but global remote
mode is active, route per-session reads/mutations to that one backend and KEEP
the ?profile= param so it opens the right state.db (instead of bailing to the
local path and dropping the profile scope).

- new globalRemoteActive() — true for connection.json mode:'remote' or the
  HERMES_DESKTOP_REMOTE_URL env override.
- per-session branch: per-profile override → route sans profile (own db);
  global mode → route to the single backend WITH ?profile= preserved.
- unified list is unchanged in global mode: it already passes through to the one
  backend, which aggregates all profiles natively.

Verified live against a one-dashboard / multi-profile remote (Austin's topology):
cross-profile transcript reads load (was 404), rename/delete route to the right
profile, unified list spans both profiles.

Known limitation (architectural, not fixed here): LIVE chat as a non-launch
profile still needs a per-profile dashboard on the remote — the dashboard binds
HERMES_HOME once at process start, so one global backend can't run an agent
turn as another profile. Session history/read/mutate now work regardless.

* fix(gateway): resume + chat any profile over one global-remote dashboard

The REST half of this branch made cross-profile session history visible in
app-global remote mode, but resume + chat still went over the WebSocket gateway,
which was hard-bound to the dashboard's launch profile. Resuming a non-launch
profile's session 404'd ("session not found") and sending spawned a new session
— because session.resume/prompt.submit had no profile concept and the live
agent + state.db were process-global to the launch profile's HERMES_HOME.

Make the WS gateway per-session profile-aware so ONE dashboard can serve every
local profile on its host (the app-global remote topology):

- session.resume accepts an optional `profile`. _profile_home() resolves that
  profile's home on this host; resume opens THAT profile's state.db, binds its
  HERMES_HOME (ContextVar override) while building the agent so config/skills/
  model resolve to it, and passes the profile db to the agent so turns persist
  to the right state.db. The owning profile_home is stored on the session.
- prompt.submit re-binds the stored profile_home for the turn thread (mid-turn
  home reads — memory, skills — resolve to the resumed profile), reset in finally.
- _make_agent gains an optional session_db param (defaults to _get_db()).
- _load_cfg honors the home override (falls back to _hermes_home) so a resumed
  profile loads its own config; cache keyed on resolved path.
- desktop: session.resume now sends the owning profile.

Omitted/launch profile → unchanged (single-profile and per-profile-remote setups
are byte-for-byte the same path). Verified live against a one-dashboard /
multi-profile remote: resuming a non-launch profile's session loads its history,
runs a real turn against THAT profile's home/env, and persists to its state.db.

tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param.
2026-06-05 12:22:55 -05:00
Brooklyn Nicholson
3045d54547 fix(desktop): route remote-profile session mutations + fix unified-list pagination
Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.

Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.

- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
  profile and pass it as request.profile so Electron routes to that profile's
  backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
  DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
  param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
  GET/PATCH (local cross-profile delete).

Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.

Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
2026-06-05 10:08:26 -05:00
Brooklyn Nicholson
83c13862f1 fix(desktop): route remote-profile session reads to the owning remote backend
Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's
remote backend, but session list + transcript reads still assumed every
profile's state.db is a local file the primary can open. For a remote profile
the local file is absent or stale, so the IDs the sidebar shows 404 the moment
resume runs against the remote -- the "session not found -> new session" bug.

Intercept the three session-read GETs in the hermes:api handler and route them
to the owning remote backend (which serves its own state.db natively):

  GET /api/profiles/sessions        -> splice each remote profile's real rows in
  GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles

No remote profiles configured -> untouched local fast path. A dead remote
contributes nothing rather than breaking the sidebar.

Verified end-to-end against a live remote backend: a remote-profile session
resumes from remote history and continues on the remote across turns (history
grows in place, no new session spawned).
2026-06-05 09:52:52 -05:00
Teknium
ca1fb32c26
docs: remove --include-desktop install instructions (#39762)
* docs: remove --include-desktop install instructions

Drop the --include-desktop curl one-liner from the desktop app docs.
The flag remains in scripts/install.sh; these docs now point to the
desktop installer / website and the 'hermes desktop' path instead.

* docs: remove --include-desktop from install docs

Drop the redundant 'Hermes Desktop installer on Linux' block (which
used --include-desktop) from quickstart, installation, and index docs.
The website installer covers macOS/Windows desktop; the CLI-only path
covers Linux. Removes the flag from all user-facing docs.
2026-06-05 06:53:58 -07:00