Mirror the bootstrap-installer (Rust) fix in the Electron first-launch
runner. spawnPowerShell launched bare 'powershell.exe', trusting PATH to
contain %SystemRoot%\System32\WindowsPowerShell\v1.0 — the same latent
weakness that stalled the native installer at "0 of 0 steps" when PATH is
trimmed/truncated or stored as a non-expanding REG_SZ. Resolve by absolute
path first (%SystemRoot%/%windir%), then PATH (powershell 5.1 -> pwsh 7),
then bare name as last resort.
Make `powershell_under_root` visible under `cfg(test)` so the
%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe layout is
asserted on any host (the rest of the resolution is gated to Windows).
The native Windows installer spawned PowerShell via the bare program name
`powershell.exe`, which trusts PATH to contain
%SystemRoot%\System32\WindowsPowerShell\v1.0. On machines whose PATH was
trimmed or truncated (Windows silently drops entries once the variable
exceeds its length limit), the lookup fails and the spawn dies with
"program not found" before install.ps1 runs at all — the installer then
stalls at "0 of 0 steps".
Resolve PowerShell by absolute path first (%SystemRoot%/%windir%), then
fall back to PATH (powershell 5.1, then pwsh 7), then a bare name as a
last resort. Also include the resolved interpreter in the spawn-failure
context; the old message printed only the script path, which misleadingly
read as if the .ps1 itself was missing.
Regression guard for the emoji-fallback fix: checks DEFAULT_TYPOGRAPHY and every
defined builtin-theme fontSans/fontMono stack contains a color-emoji font.
None of the UI sans/mono font stacks (themes/presets.ts, styles.css) carry
emoji glyphs, so on platforms whose default text font lacks them (e.g. Linux)
emoji rendered as tofu boxes in the composer and chat.
Append a color-emoji fallback — Apple Color Emoji / Segoe UI Emoji / Segoe UI
Symbol / Noto Color Emoji / the `emoji` generic — to every font stack
(SYSTEM_SANS, SYSTEM_MONO, the Courier theme, and the CSS --dt-font-* defaults).
Text still uses the primary fonts; the browser only falls back for emoji
codepoints. Custom themes build on SYSTEM_* so they inherit it automatically.
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).
* 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.
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)
The manage overlay held its own local jobs list, so deleting/creating a
job there left the sidebar's $cronJobs atom stale until the 30s poll
(delete all → section lingered). Make the overlay read and mutate the
shared atom directly (updateCronJobs), so sidebar + overlay are one
source of truth and changes show immediately.
active/createFirst/refresh/refreshing went unused when the cron overlay
moved to the shared split layout (no count header, no refresh button, no
EmptyState CTA). Remove from types + all four locales.
Cron's manage overlay now uses the shared OverlaySplitLayout (sidebar
list + main detail) instead of a bespoke PageSearchShell + grid, matching
profiles. Extract OverlayNewButton (the "+ New …" sidebar action) so
profiles and cron share one component — its hover underline is scoped to
the label span so it never strokes the leading icon glyph.
* 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
Master/detail separated by gap, not a divider; header rule, schedule-
preview chip border, and error-box border removed (subtle bg tints carry
the grouping/semantics). Fully borderless to match the flat overlay pass.
De-box the master/detail Cron page ahead of #40708's flat-UI system:
drop the two rounded-lg border/bg cards for a single --ui-stroke-tertiary
hairline between list and detail, swap the header divider and schedule-
preview chip onto the same stroke/bg-quinary tokens. No --stroke-nous
(that lands with #40708); only tokens already on this branch.
Sidebar and Cron page each carried a near-identical name→prompt→id
title fn. Collapse to a single jobTitle in cron/job-state.ts (the
page variant, which also falls back to script then 'Cron job').
Redesign the cron surface around jobs (not run sessions), following
power-user patterns (GitHub Actions / Airflow / Dagu): master → detail → output.
Sidebar "Cron jobs" section:
- jobs with a state pip + live next-run countdown
- click toggles an inline run-history peek; a run opens its chat (active run highlighted)
- hover: trigger-now + manage (open the Cron page)
- capped at 50 with a "50+" badge
Cron page: de-nested from a collapse-in-row accordion to master/detail —
job list + the selected job's schedule, actions, and run history.
Backend: GET /api/cron/jobs/{id}/runs lists a job's run sessions.
Share STATE_DOT/jobState across both surfaces; drop dead code/keys.
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>
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.
The cron scheduler tick loop only ran inside `hermes gateway run`, but the
desktop app spawns a `hermes dashboard` backend with no gateway — so any cron
a user created in the app was saved and never fired (silently).
Run a minimal scheduler ticker inside the dashboard lifespan, gated on a new
HERMES_DESKTOP=1 marker the electron shell injects, so server `hermes dashboard`
is unaffected. Cross-process safe via the existing cron/.tick.lock, so it never
double-fires alongside a real gateway.
Scheduler sessions (source=cron) were listed in recents, where their
`[IMPORTANT: …]` first-message previews spammed the list — and because
cron runs are always newest, a burst of them consumed the whole recents
page budget and starved real conversations (sidebar showed 0 sessions).
Recents and cron jobs are now two independent lists:
- Backend: /api/sessions + /api/profiles/sessions accept source /
exclude_sources; session_count gains exclude_sources. Recents query
excludes cron; the cron section queries source=cron.
- Desktop: separate $cronSessions store + refreshCronSessions fetch, a
collapsed (persisted) "Cron jobs" section below Sessions that only
renders when cron sessions exist, with its own bounded scroller.
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.
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.
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.
* 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>
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).
- 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.
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.
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).
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.
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.
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).
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.
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.
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).