Compare commits

...

36 Commits

Author SHA1 Message Date
nesquena-hermes
c6017f461b docs: v0.36.2 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 13:59:45 -07:00
Nathan Esquenazi
e829fa50d5 fix: OpenRouter models stripped of prefix, causing 404 (#116)
When config has provider=openrouter and model=openrouter/free,
resolve_model_provider() stripped the 'openrouter/' prefix because
prefix == config_provider. This sent 'free' to OpenRouter's API,
which returned 404 (model not found).

OpenRouter always needs the full provider/model path (e.g.
openrouter/free, anthropic/claude-sonnet-4.6). The prefix-stripping
logic is only correct for direct-API providers.

Fix: skip prefix stripping entirely when config_provider is 'openrouter'.
Return the full model_id with provider='openrouter'.

Added 7 unit tests for resolve_model_provider() covering:
- openrouter/free keeps full path (the bug)
- openrouter cross-provider models keep full path
- direct API providers still strip prefix correctly
- cross-provider routing to openrouter
- bare model names use config provider
- empty model returns defaults

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:58:37 -07:00
nesquena-hermes
1777cf7bfe docs: v0.36.1 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 12:46:58 -07:00
Nathan Esquenazi
48ba2e79e2 fix: Enter key reliably submits login form (#124)
The login form used 'return doLogin(event)' in onsubmit, but doLogin is
async so it returns a Promise (truthy), which some browsers interpret as
'proceed with native form submit'. Changed to 'doLogin(event);return false'
and added an explicit onkeydown Enter handler on the password input as
belt-and-suspenders.

Closes #124

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:45:57 -07:00
Nathan Esquenazi
52e3fb70e0 Merge pull request #123 from nesquena/docs/update-parity-assessment
docs: update SPRINTS parity assessment from v0.21 to v0.36
2026-04-05 10:10:49 -07:00
Nathan Esquenazi
c1bbdf9aeb docs: update SPRINTS parity assessment from v0.21 to v0.36
CLI parity: ~90% -> ~95% (added profiles, CLI bridge, compaction,
self-update, git detection, slash commands, rAF throttle)

Claude parity: ~70% -> ~85% (added themes, voice, mobile responsive,
collapsible groups, context indicator, token display, Docker, subagent
delegation cards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:10:33 -07:00
nesquena-hermes
3ca7f08b59 docs: sweep markdown for v0.36
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 10:00:45 -07:00
Nathan Esquenazi
af76622c97 Merge pull request #121 from nesquena/feat/self-update-checker
Some checks failed
Release & Docker / release (push) Has been cancelled
feat: self-update checker with one-click update
2026-04-05 09:27:39 -07:00
Nathan Esquenazi
27706367b7 docs: v0.36 release notes, version bump for self-update checker
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:27:27 -07:00
Nathan Esquenazi
beb56b1a8b fix: apply_update concurrency lock, boot.js settings-fail guard, dead workspace code, test_updates URL param
- api/updates.py: add _apply_lock to prevent concurrent stash/pull/pop
- static/boot.js: set check_for_updates:false on settings fetch failure
- static/panels.js: remove dead settingsWorkspace references (element removed from HTML)
- api/routes.py + static/boot.js: add ?test_updates=1 URL param for testing banner
  without being behind on git (localhost-only simulate endpoint)
2026-04-05 16:20:12 +00:00
Nathan Esquenazi
8d1b7a1e01 feat: self-update checker with one-click update for WebUI + Agent
Shows a blue banner when the webui or hermes-agent git repos are behind
their upstream branches. One-click 'Update Now' button does stash, pull
--ff-only, stash pop, then reloads the page.

Backend (api/updates.py):
- _check_repo(): git fetch + rev-list count with 15s timeout
- check_for_updates(): 30-min server-side cache, thread-safe, skips
  Docker (no .git dir)
- apply_update(): stash (if dirty), pull --ff-only, pop, invalidate cache

Routes:
- GET /api/updates/check -- returns cached {webui, agent} with behind count
- POST /api/updates/apply -- {target: 'webui'|'agent'}

Frontend:
- Blue banner (matches reconnect-banner pattern) with 'Later' / 'Update Now'
- Non-blocking boot check via fire-and-forget .then(), once per tab session
- sessionStorage guards prevent re-checking and re-showing after dismiss

Settings:
- 'Check for updates' checkbox (default: on) -- when off, no git operations
- Removed 'Default Workspace' dropdown to keep settings panel compact

Performance:
- Server cache: git fetch at most 2x/hour regardless of client count
- sessionStorage: one check per browser tab session
- _check_in_progress flag prevents concurrent fetch storms
- Fire-and-forget: does NOT block the boot sequence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:11:44 -07:00
nesquena-hermes
257092d107 docs: v0.35.1 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 08:31:15 -07:00
Nathan Esquenazi
b327103885 fix: model dropdown missing custom/configured models (#116, #117)
Two related bugs in get_available_models():

1. cfg_base_url undefined for string model configs (#117):
   cfg_base_url was defined inside 'elif isinstance(model_cfg, dict)'
   but referenced unconditionally at line 506. If model config was a
   plain string, NameError crashed model detection. Fix: initialize
   cfg_base_url='' before the conditional.

2. Configured default_model missing from dropdown (#116):
   The OpenRouter branch substituted _FALLBACK_MODELS without checking
   if the user's model.default was in the list. Models like
   'openrouter/free' or custom local models were invisible. Fix: after
   building all groups, check if default_model is present. If not,
   inject it at the top of the matching provider group.

Closes #116, closes #117

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 08:29:40 -07:00
nesquena-hermes
df9ad1fd27 fix: initialize cfg_base_url for custom providers
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 08:25:20 -07:00
Nathan Esquenazi
2f01afd557 Merge pull request #115 from mangodxd/cleanup/fix-py-issues-2a3ea49e
chore: add missing type hints (10 files)
2026-04-04 23:55:21 -07:00
Nathan Esquenazi
74fcd2e0ab fix: correct 9 inaccurate type hints
- get_password_hash() -> str | None (not bool, returns hash or None)
- parse_cookie() -> str | None (not None, returns cookie value)
- Session.__init__ session_id: str (not int, uuid hex)
- Session.__init__ project_id: str (not int)
- Session.__init__ **kwargs (remove incorrect dict annotation)
- Session.load() remove -> None (returns Session | None)
- import_cli_session session_id: str (not int)
- sync_session_start session_id: str (not int)
- sync_session_usage session_id: str (not int)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:54:58 -07:00
Nguyễn Công Thuận Huy
4d333acbbc chore: add missing type hints across 10 files 2026-04-05 13:30:20 +07:00
Nathan Esquenazi
3d063b08a9 rebuild: clean public history after AGENTS.md rewrite removal 2026-04-05 06:25:24 +00:00
nesquena-hermes
814e016965 Update image descriptions in README.md (#111) 2026-04-04 22:28:32 -07:00
nesquena-hermes
0119365bd8 docs: v0.35 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:27:04 -07:00
Nathan Esquenazi
39066bc614 security: fix env race, signing key, upload traversal, password hash (#106)
* security: fix four audit findings -- env race, signing key, upload traversal, password hash

1. Race condition in os.environ (HIGH): Per-session _agent_lock didn't
   prevent cross-session env writes from interleaving. Added global
   _ENV_LOCK in streaming.py that serializes the entire env save/restore
   block across all sessions.

2. Predictable signing key (MEDIUM): sha256(STATE_DIR) was deterministic.
   Now generates a random 32-byte key on first startup and persists it to
   STATE_DIR/.signing_key (chmod 600). Existing sessions invalidated on
   first restart (acceptable for a security fix).

3. Upload path traversal (MEDIUM): Filename '..' survived the regex
   sanitization (dots are allowed chars). Added explicit rejection of
   dot-only names and safe_resolve_ws() check to verify the resolved
   path stays within the workspace.

4. Weak password hashing (MEDIUM): Replaced bare SHA-256 with PBKDF2-
   SHA256 (600k iterations per OWASP). Uses stdlib hashlib.pbkdf2_hmac,
   no new dependencies. Note: existing passwords must be re-set after
   this change (hash format changed).

Closes #106

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use random signing key as PBKDF2 salt (replaces predictable STATE_DIR salt)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:25:08 -07:00
nesquena-hermes
853b23cd14 docs: README screenshot refresh + full markdown sweep (v0.34.3, 433 tests, Sprint 26 completed)
* Revise images and enhance layout description in README

Updated images and added new content to the layout section.

* docs: markdown sweep -- v0.34.3, 433 tests, Sprint 26 completed, custom themes row restored

- THEMES.md: restore custom themes row removed by PR #105
- ROADMAP.md: bump version/tests to v0.34.3/433; mark themes [x]; add v0.34/v0.34.1/v0.34.2/v0.34.3 to sprint history table
- SPRINTS.md: Sprint 26 marked COMPLETED; version bumped to v0.34.3; horizon sprint updated to Sprint 25 (Desktop)
- TESTING.md: coverage updated to Sprint 26 / v0.34.3; test count corrected to 433; port corrected to 8786

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:22:32 -07:00
nesquena-hermes
cf3ccb0666 docs: v0.34.3 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:12:37 -07:00
Nathan Esquenazi
2f58724863 fix: light theme final polish -- sidebar, roles, chips, all interactive elements
* fix: light theme sidebar, roles, chips, active states -- full polish

Comprehensive light theme overrides for every remaining hardcoded
dark-theme element:

Sidebar:
- Session items: warm dark text instead of faint muted gray
- Active session: blue accent (matching --blue) instead of washed-out gold
- Pin stars/headers: deep gold #996b15 instead of bright yellow #f5c542
- Session actions gradient: light bg instead of dark overlay
- Search input: dark borders, proper focus ring

Role labels:
- You: solid #2d6fa3 blue instead of faint rgba(124,185,255,0.65)
- Hermes: solid #8a6520 gold instead of faint rgba(201,168,76,0.6)
- Role icons: proper bg/border contrast for light backgrounds

Chips and interactive elements:
- Project chips: dark borders, dark hover states
- Model chip: blue accent matching theme
- New chat button: blue accent borders
- All hover states: rgba(0,0,0,.XX) instead of rgba(255,255,255,.XX)

Other surfaces:
- Composer box borders and focus ring
- Tool cards, cron items, suggestions
- File tree hover, preview badges
- Profile/workspace dropdown hovers
- Settings, nav tooltips

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update THEMES.md with all current CSS variables

Added typography variables (--strong, --em, --code-text, --code-inline-bg,
--pre-text) to the custom theme guide. Added note about light theme
selector overrides needed for hover/border contrast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:11:42 -07:00
nesquena-hermes
a3f4ad7111 docs: update THEMES.md for theme contract
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:05:50 -07:00
nesquena-hermes
0ed2981205 docs: v0.34.2 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 22:00:15 -07:00
Nathan Esquenazi
5762aaafba fix: theme-aware text colors -- light mode readable, all themes polished
Added 5 new CSS variables to every theme block:
--strong, --em, --code-text, --code-inline-bg, --pre-text

Light theme: dark brown text, warm gray italics, saddle brown code on
subtle bg. All previously invisible text is now readable.

All themes get palette-appropriate values matching their design language
(Solarized orange, Monokai yellow, Nord green, etc).

Also fixed: remaining white borders to var(--border), light scrollbar,
code-bg contrast, settings overlay, approval card text.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:59:12 -07:00
nesquena-hermes
3294e54e00 docs: v0.34.1 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 21:45:23 -07:00
Nathan Esquenazi
2eddef3275 fix: replace 30+ hardcoded dark-navy colors with theme CSS variables
Root cause: topbar, dropdowns, toast, approval card, tooltips, main area,
inputs, and hover states all used hardcoded rgba(22,33,62), #1a2535, etc.
These only looked correct on the Dark theme — all other themes showed
jarring dark-navy elements on non-navy backgrounds.

New CSS variables added to every theme block:
- --surface: dropdowns, popups, toast, approval card
- --topbar-bg: topbar background
- --main-bg: main chat area background
- --input-bg: subtle input/button backgrounds
- --hover-bg: hover state backgrounds
- --focus-ring / --focus-glow: focus border and box-shadow

Light theme now has proper light-colored surfaces, inputs, and hover
states instead of invisible white-on-white.

THEMES.md updated with all new variables documented.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:43:53 -07:00
Nathan Esquenazi
7bcd6623e9 Merge pull request #98 from nesquena/sprint-26-themes
Some checks failed
Release & Docker / release (push) Has been cancelled
Sprint 26: Pluggable UI themes — dark, light, solarized, monokai, nord
2026-04-04 21:13:10 -07:00
Nathan Esquenazi
82a942a2b1 docs: v0.34 release — themes CHANGELOG, README, add light to picker
- CHANGELOG: v0.34 Sprint 26 entry (6 themes, /theme command, settings UX)
- README: themes section, updated slash commands, THEMES.md in docs list
- THEMES.md: added Slate to theme table, matches actual CSS/dropdown
- commands.js: added 'light' to /theme valid list, updated description
- index.html: added Light option to theme dropdown, version v0.34
- SPRINTS/CHANGELOG footers updated to v0.34 / 433 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:13:01 -07:00
Nathan Esquenazi
805fa296c8 fix: cut light theme from picker, shorten Save button label 2026-04-05 04:06:02 +00:00
Nathan Esquenazi
b8b063f325 fix: settings panel taller -- show Save button without scrolling 2026-04-05 04:01:56 +00:00
Nathan Esquenazi
882fc947e5 fix: settings unsaved-changes guard, add Slate theme, improve Light theme
Unsaved-changes guard:
- _closeSettingsPanel() intercepts all three close paths (X button, overlay
  click, Escape key) and checks _settingsDirty before closing
- If dirty: shows inline 'Unsaved changes' bar with Save & Close / Discard
- Discard reverts the live theme preview to what it was when panel opened
- _markSettingsDirty() wired to all inputs via addEventListener in loadSettingsPanel()
- saveSettings() now resets dirty flag and hides the bar on successful save

Theme improvements:
- Add 'Slate' theme: warm charcoal (#2b2d30 bg), a softer/lighter dark option
  that sits between Dark and the full light themes
- Rework 'Light' theme: replace pure white (#f5f5f7) with warm off-white
  (#f0ede8) -- warmer, lower contrast, less harsh on most displays
- Update /theme command to include 'slate' in valid list
- Add test_settings_set_theme_slate() to test_sprint26.py
2026-04-05 04:00:24 +00:00
Nathan Esquenazi
96137750a4 feat: Sprint 26 — pluggable UI themes (dark, light, solarized, monokai, nord)
Five built-in themes with instant switching, persistent preference,
and zero-flicker loading. Custom themes are pure CSS additions.

Theme system:
- CSS variable overrides via :root[data-theme="name"] blocks
- Flicker prevention: inline <script> reads localStorage before
  stylesheet parses, preventing dark-flash on light-mode users
- Server-side persistence via settings.json (theme field)
- Boot.js syncs server preference to DOM + localStorage

Built-in themes:
- Dark (default): deep navy/indigo, muted blue accents
- Light: clean white/gray, high contrast, scrollbar overrides
- Solarized Dark: teal background, warm accents
- Monokai: warm dark, green/pink accents
- Nord: arctic blue-gray, calm and minimal

UI integration:
- Settings panel: theme dropdown with instant live preview
- /theme slash command: /theme dark|light|solarized|monokai|nord
- No enum constraint on theme setting — custom themes just work

Documentation:
- THEMES.md: how to switch themes, create custom themes, contribute

8 new tests. All 408 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:48:05 -07:00
nesquena-hermes
d10871c0e4 docs: Sprint 26 themes plan + Sprint 12/13/23/24 cleanup
- Sprint 12 and 13 headers: add missing (COMPLETED) labels
- Sprint 23 header: corrected from 'Profile/Workspace/Model Coherence' to
  'Agentic Transparency + Context Visibility' (what it actually shipped)
- Sprint 24 Track C: removed stale self-referential cleanup items that are now done
- Sprint 26 added: full plan for pluggable UI themes (light/dark/solarized/monokai/nord)
  including CSS variable architecture, flicker prevention, /theme slash command,
  settings picker with live preview, and test spec
- ROADMAP.md: add v0.32/v0.33 to sprint history table, add Sprint 25/26 to feature checklist
- SPRINTS.md footer: add horizon sprint line

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 20:36:42 -07:00
26 changed files with 1501 additions and 182 deletions

View File

@@ -5,6 +5,139 @@
---
## [v0.36.2] OpenRouter model routing fix
*April 5, 2026 | 440 tests*
### Bug Fixes
- **OpenRouter models sent without prefix, causing 404 (#116).** `resolve_model_provider()` was stripping the `openrouter/` prefix from model IDs (e.g. sending `free` instead of `openrouter/free`) when `config_provider == 'openrouter'`. OpenRouter requires the full `provider/model` path to route upstream correctly. Fixed with an early return that preserves the complete model ID for all OpenRouter configs. (#127)
- Added 7 unit tests for `resolve_model_provider()` — first coverage on this function. Tests the regression, cross-provider routing, direct-API prefix stripping, bare models, and empty model.
---
## [v0.36.1] Login form Enter key fix
*April 5, 2026 | 433 tests*
### Bug Fixes
- **Login form Enter key unreliable in some browsers (#124).** `onsubmit="return doLogin(event)"` returned a Promise (async functions always return a truthy Promise), which could let the browser fall through to native form submission. Fixed with `doLogin(event);return false` plus an explicit `onkeydown` Enter handler on the password input as belt-and-suspenders. (#125)
---
## [v0.36] Self-Update Checker with One-Click Update
*April 5, 2026 | 433 tests*
### Features
- **Update checker.** Non-blocking background check on boot detects when the
WebUI or hermes-agent git repos are behind upstream. Blue banner shows
"WebUI: N updates, Agent: N updates available" with Update Now / Later.
- **One-click update.** "Update Now" runs `git stash && git pull --ff-only &&
git stash pop` on each behind repo, then reloads the page. Concurrent update
attempts blocked via lock. Dirty working trees safely stashed and restored.
- **Settings toggle.** "Check for updates" checkbox in Settings panel. Persisted
server-side. Disabled = no background fetch, no banner.
- **30-minute cache.** Git fetch runs at most twice per hour regardless of tab
count. Results cached server-side with TTL.
- **Session-scoped dismissal.** "Later" dismisses banner for the current tab
session (sessionStorage). New tabs get a fresh check.
- **Test mode.** `?test_updates=1` URL param shows the banner with fake data
(localhost only) for UI testing without needing to actually be behind.
### Architecture
- New `api/updates.py`: `check_for_updates()`, `apply_update()`. Thread-safe
caching with `_cache_lock`. Concurrent apply blocked with `_apply_lock`.
Default branch auto-detected (master/main).
- `api/routes.py`: `GET /api/updates/check`, `POST /api/updates/apply`.
Simulate endpoint gated to 127.0.0.1.
- `static/ui.js`: `_showUpdateBanner()`, `dismissUpdate()`, `applyUpdates()`.
- `static/boot.js`: fire-and-forget check on boot (does not block UI).
- `api/config.py`: `check_for_updates` in settings defaults + bool keys.
- Docker safe: all git ops gated by `.git` directory existence check.
---
## [v0.35.1] Model dropdown fixes
*April 5, 2026 | 433 tests*
### Bug Fixes
- **Custom providers invisible in model dropdown (#117).** `cfg_base_url` was scoped inside a conditional block but referenced unconditionally, causing a `NameError` for users with a `base_url` in config.yaml. Fix: initialize to `''` before the block. (#118)
- **Configured default model missing from dropdown (#116).** OpenRouter and other providers replaced the model list with a hardcoded fallback that didn't include `model.default` values like `openrouter/free` or custom local model names. Fix: after building all groups, inject the configured `default_model` at the top of its provider group if absent. (#119)
---
## [v0.35] Security hardening
*April 5, 2026 | 433 tests*
### Security fixes
- **ENV race condition (HIGH):** Two concurrent sessions could interleave `os.environ` writes, clobbering workspace and session keys. Fixed with a global `_ENV_LOCK` in `streaming.py` that serializes the env save/restore block across all sessions. (#108)
- **Predictable signing key (MEDIUM):** Session cookies were signed with `sha256(STATE_DIR)` -- deterministic and forgeable if the install path is known. Now generates a cryptographically random 32-byte key on first startup, persisted to `STATE_DIR/.signing_key` (chmod 600). (#108)
- **Upload path traversal (MEDIUM):** Filenames like `..` survived the `[^\w.\-]` sanitization regex because dots are allowed. Fixed by rejecting dot-only filenames and validating the resolved path stays within the workspace sandbox via `safe_resolve_ws()`. (#108)
- **Weak password hashing (MEDIUM):** Bare SHA-256 with a predictable salt replaced with PBKDF2-SHA256 at 600k iterations (OWASP recommendation) using the random signing key as salt. No new dependencies (stdlib `hashlib.pbkdf2_hmac`). (#108)
**Breaking change:** Existing session cookies and password hashes are invalidated on first restart after upgrade. Users with password auth enabled will need to re-set their password.
---
## [v0.34.3] Light theme final polish
*April 5, 2026 | 433 tests*
### Bug Fixes
- **Light theme: sidebar, role labels, chips, and interactive elements all broken.** Session titles were too faint, active session used washed-out gold, pin stars were near-invisible bright yellow, and all hover/border effects used dark-theme white `rgba(255,255,255,.XX)` values invisible on cream. Fixed with 46 scoped `[data-theme="light"]` selector overrides covering session items, role labels, project chips, topbar chips, composer, suggestions, tool cards, cron list, and more. (#105)
- Active session now uses blue accent (`#2d6fa3`) for strong contrast. Pin stars use deep gold (`#996b15`). Role labels are solid and high contrast.
---
## [v0.34.2] Theme text colors
*April 5, 2026 | 433 tests*
### Bug Fixes
- **Light mode text unreadable.** Bold text was hardcoded white (invisible on cream), italic was light purple on cream, inline code had a dark box on a light background. Fixed by introducing 5 new per-theme CSS variables (`--strong`, `--em`, `--code-text`, `--code-inline-bg`, `--pre-text`) defined for every theme. (#102)
- Also replaced remaining `rgba(255,255,255,.08)` border references with `var(--border)`, and darkened light theme `--code-bg` slightly for better contrast.
---
## [v0.34.1] Theme variable polish
*April 5, 2026 | 433 tests*
### Bug Fixes
- **All non-dark themes had broken surfaces, topbar, and dropdowns.** 30+ hardcoded dark-navy rgba/hex values in style.css were stuck on the Dark palette regardless of active theme. Fixed by introducing 7 new CSS variables (`--surface`, `--topbar-bg`, `--main-bg`, `--input-bg`, `--hover-bg`, `--focus-ring`, `--focus-glow`) defined per-theme, replacing every hardcoded reference. (#100)
---
## [v0.34] Sprint 26 -- Pluggable UI Themes
*April 5, 2026 | 433 tests*
### Features
- **6 built-in themes.** Dark (default), Light, Slate, Solarized Dark, Monokai,
Nord. Defined as CSS variable overrides on `:root[data-theme="name"]` — the
entire UI adapts automatically.
- **Theme picker in Settings.** Dropdown with instant live preview. Changes
apply immediately as you click through options.
- **`/theme` slash command.** `/theme dark`, `/theme light`, etc.
- **Theme persistence.** Saved server-side in `settings.json` and client-side
in `localStorage` for flicker-free loading on page refresh.
- **Flash prevention.** Inline `<script>` in `<head>` reads localStorage before
the stylesheet loads — no flash of the wrong theme.
- **Custom theme support.** Any theme name is accepted (no enum gate). Create a
`:root[data-theme="name"]` CSS block and it works. See `THEMES.md`.
- **Unsaved changes guard.** Settings panel now tracks dirty state and shows a
"You have unsaved changes" bar with Save/Discard buttons when closing with
unpersisted changes. Theme preview reverts on discard.
### Architecture
- `static/style.css`: 6 theme blocks using CSS variable overrides. Light theme
includes scrollbar and selection overrides.
- `static/commands.js`: `/theme` command with validation.
- `static/panels.js`: Settings dirty tracking, revert-on-discard, unsaved bar.
- `static/boot.js`: Theme applied from server settings on boot.
- `api/config.py`: `theme` field in `_SETTINGS_DEFAULTS` (no enum gate).
- `THEMES.md`: Full documentation for creating custom themes.
### Tests
- 9 new tests in `test_sprint26.py`: default theme, round-trip persistence for
all 6 built-in themes, custom theme acceptance, settings isolation.
Total: **433 tests**.
---
## [v0.33] /insights Sync + state.db Bridge Fix
*April 5, 2026 | 424 tests*
@@ -1152,4 +1285,7 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: v0.32, April 5, 2026 | Tests: 424*
*Last updated: v0.36, April 5, 2026 | Tests: 433*
### Markdown sweep
- ROADMAP.md, TESTING.md, SPRINTS.md, README.md, and THEMES.md refreshed to match v0.36 and 433 tests.

View File

@@ -10,7 +10,20 @@ and vanilla JS.
Layout: three-panel Claude-style. Left sidebar for sessions and tools,
center for chat, right for workspace file browsing.
<img width="1392" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/79cd3c0d-3167-42ed-9434-447a742c25c3" />
<img alt="Hermes Web UI — three-panel layout" width="1417" height="867" alt="image" src="https://github.com/user-attachments/assets/51adff98-53ee-4800-8508-78b6c34dd3dc" />
<table>
<tr>
<td width="50%" align="center">
<img alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/9b68142f-d974-4493-a8d1-fd73e622c7fd" />
<br /><sub>Light mode with full profile support</sub>
</td>
<td width="50%" align="center">
<img alt="Customize your settings, configure a password" src="https://github.com/user-attachments/assets/941f3156-21e3-41fd-bcc8-f975d5000cb8" />
<br /><sub>Customize your settings, configure a password</sub>
</td>
</tr>
</table>
<table>
<tr>
@@ -202,6 +215,40 @@ are running over SSH.
---
## Accessing on your phone with Tailscale
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on
WireGuard. Install it on your server and your phone, and they join the same
private network -- no port forwarding, no SSH tunnels, no public exposure.
The Hermes Web UI is fully responsive with a mobile-optimized layout
(hamburger sidebar, bottom navigation bar, touch-friendly controls), so it
works well as a daily-driver agent interface from your phone.
**Setup:**
1. Install [Tailscale](https://tailscale.com/download) on your server and
your iPhone/Android.
2. Start the WebUI listening on all interfaces with password auth enabled:
```bash
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh
```
3. Open `http://<server-tailscale-ip>:8787` in your phone's browser
(find your server's Tailscale IP in the Tailscale app or with
`tailscale ip -4` on the server).
That's it. Traffic is encrypted end-to-end by WireGuard, and password auth
protects the UI at the application level. You can add it to your home screen
for an app-like experience.
> **Tip:** If using Docker, set `HERMES_WEBUI_HOST=0.0.0.0` in your
> `docker-compose.yml` environment (already the default) and set
> `HERMES_WEBUI_PASSWORD`.
---
## Manual launch (without start.sh)
If you prefer to launch the server directly:
@@ -237,8 +284,8 @@ Or using the agent venv explicitly:
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched. Current count: **424 tests**
across 22 test files.
Production data and real cron jobs are never touched. Current count: **433 tests**
across 23 test files.
---
@@ -314,17 +361,24 @@ across 22 test files.
- 20MB POST body size limit
- CDN resources pinned with SRI integrity hashes
### Themes
- 6 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord
- Switch via Settings panel dropdown (instant live preview) or `/theme` command
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
### Settings and configuration
- Settings panel (gear icon) -- default model, default workspace, send key preference
- Settings panel (gear icon) -- default model, default workspace, send key, theme
- Send key: Enter (default) or Ctrl/Cmd+Enter
- Show/hide CLI sessions toggle (enabled by default)
- Token usage display toggle (off by default, also via `/usage` command)
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
- Background agent error alerts -- banner when a non-active session encounters an error
### Slash commands
- Type `/` in the composer for autocomplete dropdown
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`, `/compact`
- Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent
@@ -393,6 +447,7 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
- `TESTING.md` -- manual browser test plan and automated coverage reference
- `CHANGELOG.md` -- release notes per sprint
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
- `THEMES.md` -- theme system documentation, custom theme guide
## Repo

View File

@@ -3,8 +3,8 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.33 (April 5, 2026)
> Tests: 424 total (424 passing, 0 failures)
> Last updated: v0.36 (April 5, 2026)
> Tests: 433 total (433 passing, 0 failures)
> Source: <repo>/
---
@@ -40,6 +40,13 @@
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
| v0.34.1 | Theme variable polish | 30+ hardcoded dark-navy colors replaced with theme-aware CSS variables | 433 |
| v0.34.2 | Theme text colors | 5 new per-theme typography variables (--strong, --em, --code-text, --code-inline-bg, --pre-text) | 433 |
| v0.34.3 | Light theme final polish | 46 light-scoped selector overrides for sidebar, roles, chips, interactive elements | 433 |
| v0.35 | Security hardening | Env race fix, random signing key, upload path traversal, PBKDF2 password hash | 433 |
---
@@ -206,7 +213,8 @@
- [ ] TTS playback of responses (deferred)
- [x] Background task cancel (activity bar Cancel button)
- [ ] Code execution cell (deferred)
- [ ] Desktop application (deferred)
- [ ] Desktop application (Sprint 25, PLANNED)
- [x] Pluggable UI themes -- Dark, Light, Slate, Solarized, Monokai, Nord (Sprint 26, v0.34)
- [ ] Extended slash command / skill integration (deferred)
- [ ] Virtual scroll for large lists (deferred)

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan
> Current state: v0.32 | 424 tests | Daily driver ready
> Current state: v0.36 | 433 tests | Daily driver ready
> This document plans the path from here to two targets:
>
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
@@ -14,19 +14,27 @@
---
## Where we are now (v0.21)
## Where we are now (v0.36)
**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
file ops with tree view, cron/skills/memory CRUD, session management, streaming,
cancel, multi-provider models, custom endpoint discovery, slash commands,
thinking/reasoning display, password auth -- all solid. Gaps are subagent
visibility, toolset control, and code execution.
**CLI parity: ~95% complete.** Core agent loop, all tools visible, workspace
file ops with tree view and git detection, cron/skills/memory CRUD, session
management, streaming with rAF throttle, cancel, multi-provider models, custom
endpoint discovery, slash commands (help/clear/model/workspace/new/usage/theme/compact),
thinking/reasoning display, password auth, multi-profile support with seamless
switching, CLI session bridge (read and import from state.db), context
auto-compaction handling, self-update checker. Remaining gaps: subagent
session tree, toolset control per session, code execution cells.
**Claude parity: ~70% complete.** Chat, streaming, file browser, session
management, tool cards, syntax highlighting, model switching, projects,
settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
commands, thinking display, auth -- all present. Gaps are artifacts, voice,
TTS, sharing, mobile-optimized layout.
**Claude parity: ~85% complete.** Chat, streaming, file browser, session
management with projects and tags, tool cards with subagent delegation,
syntax highlighting, model switching, Mermaid diagrams, mobile responsive
layout (hamburger sidebar, bottom nav, files slide-over), breadcrumb
workspace nav with tree view, slash commands, thinking/reasoning display,
auth with signed cookies, 6 pluggable UI themes (dark/light/slate/solarized/
monokai/nord), voice input (Web Speech API), collapsible date groups,
context usage indicator, token/cost display, git branch badge, Docker
support. Remaining gaps: artifacts (HTML/SVG preview), TTS playback,
sharing/public URLs, code execution inline.
---
@@ -75,7 +83,7 @@ heavy agentic work.
---
## Sprint 12 -- Settings Panel + Reliability + Session QoL
## Sprint 12 -- Settings Panel + Reliability + Session QoL (COMPLETED)
**Theme:** Persist your preferences, survive network blips, and organize sessions.
@@ -118,7 +126,7 @@ to keep important conversations accessible.
---
## Sprint 13 -- Alerts, Session QoL, Polish
## Sprint 13 -- Alerts, Session QoL, Polish (COMPLETED)
**Theme:** Know what Hermes is doing, and small quality-of-life wins.
@@ -511,15 +519,14 @@ single default profile, blocking multi-persona workflows.
---
## Sprint 23 -- Profile/Workspace/Model Coherence (COMPLETED)
## Sprint 23 -- Agentic Transparency + Context Visibility (COMPLETED)
**Theme:** Make profiles, workspaces, models, and sessions coherent across
profile switches.
**Theme:** Surface what the agent is doing and how much context it's using.
**Why now:** Sprint 22 added profile switching but five coherence bugs remained:
the model picker ignored the profile's default, workspaces were a global file,
DEFAULT_WORKSPACE was a startup singleton, the session list showed all profiles,
and switchToProfile() didn't refresh workspaces or sessions.
**Why now:** Users had no visibility into tool call arguments, session token
usage, or context window fill. Sprint 22 left five coherence bugs in the
profile/workspace/model flow that also needed closing before the UI felt
reliable.
### Track A: Bugs
- **Model picker ignores profile on switch.** `populateModelDropdown()` skipped
@@ -611,12 +618,9 @@ the app to others.
CSS `contain: strict` + IntersectionObserver approach, no library needed.
### Track C: Code Quality
- **SPRINTS.md + ROADMAP.md + CHANGELOG.md updated** to reflect Sprint 23
completion (agentic transparency) and correct test counts.
- **Remove stale Sprint 23 description** from SPRINTS.md (the "Profile/Workspace
coherence" text is from an older plan; Sprint 23 actually shipped agentic
transparency features).
- **CHANGELOG entry for v0.29** covering Sprint 23 deliverables.
- Audit and remove any remaining dead code introduced by Sprint 23 (e.g. `S.lastUsage` assignment in messages.js that nothing reads).
- Verify tool call args render correctly in settled history cards on session reload.
- Update test count in all docs to match actual pytest output after sprint merges.
**Estimated tests:** ~10 new. Target total: ~435.
**Hermes CLI parity impact:** Low
@@ -897,6 +901,270 @@ genuinely differentiating for an open-source project
---
## Sprint 26 -- Pluggable UI Themes (COMPLETED)
**Theme:** Let users choose how the app looks -- light, dark, and custom color
schemes. One-click switching, persistent preference, zero flicker on load.
**Difficulty: Low-Medium.** The existing CSS is already 100% CSS-variable-driven
off a single `:root` block. Every color, background, and accent in the entire UI
is already a variable. Adding themes is mostly a matter of defining alternative
`:root` overrides and wiring a picker -- not a rewrite. The main engineering
work is flicker prevention on load and the settings UI.
**Estimated effort:** 1 sprint, ~2 days of implementation. 8-12 new tests.
---
### Why now
The UI ships only one dark theme. Contributors have asked for light mode. Power
users want to match their terminal colorscheme. This is low-risk, high-value
polish that makes the app feel more finished and more personal. It's also a
good precedent-setter: once the theme system exists, community members can
contribute new themes as a pure CSS addition with no Python changes needed.
---
### Design decisions
**Themes are CSS-variable overrides, not separate stylesheets.** Each theme is
a named `:root[data-theme="name"]` block. The base stylesheet stays untouched.
Switching themes sets `document.documentElement.dataset.theme = name` in JS.
No FOUC (flash of unstyled content), no stylesheet swap latency.
**Theme preference persists server-side in `settings.json`.** Same mechanism
as `send_key` and `show_token_usage`. The server includes `theme` in the
`GET /api/settings` response. Boot.js reads it and applies before first paint.
**Flicker prevention.** A tiny inline `<script>` in `<head>` (before the
stylesheet link) reads `localStorage.getItem('hermes-theme')` and sets
`document.documentElement.dataset.theme` synchronously. This prevents a
dark-flash on light-mode users during the round-trip to `/api/settings`.
The localStorage value is kept in sync whenever the user changes themes.
**No third-party dependencies.** Pure CSS + vanilla JS. No theme library.
---
### Track A: Core theme system
**1. CSS variable blocks in `static/style.css`**
The existing `:root` block becomes the `dark` (default) theme. Add named
theme blocks immediately after:
```css
/* ── Default (dark) theme ── already in :root ── */
:root[data-theme="light"] {
--bg: #f5f5f7;
--sidebar: #e8e8ed;
--border: rgba(0,0,0,0.10);
--border2: rgba(0,0,0,0.16);
--text: #1c1c1e;
--muted: #6e6e80;
--accent: #c0392b;
--blue: #0a6dc2;
--gold: #a07a20;
--code-bg: #f0f0f5;
}
:root[data-theme="solarized"] {
--bg: #002b36;
--sidebar: #073642;
--border: rgba(255,255,255,0.08);
--border2: rgba(255,255,255,0.13);
--text: #839496;
--muted: #657b83;
--accent: #dc322f;
--blue: #268bd2;
--gold: #b58900;
--code-bg: #073642;
}
:root[data-theme="monokai"] {
--bg: #272822;
--sidebar: #1e1f1c;
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--text: #f8f8f2;
--muted: #75715e;
--accent: #f92672;
--blue: #66d9e8;
--gold: #e6db74;
--code-bg: #1e1f1c;
}
:root[data-theme="nord"] {
--bg: #2e3440;
--sidebar: #272c36;
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--text: #eceff4;
--muted: #9099aa;
--accent: #bf616a;
--blue: #81a1c1;
--gold: #ebcb8b;
--code-bg: #272c36;
}
```
Additional theming notes:
- `syntax-highlight` colors (Prism.js) are theme-independent (they come from the
CDN stylesheet) -- acceptable for v1.
- The logo gradient (`linear-gradient(145deg,#e8a030,var(--accent))`) uses
`--accent` already so it adapts automatically.
- Scrollbar colors and `::selection` backgrounds need explicit overrides in the
light theme to avoid dark scrollbars on a light background.
**2. Flicker-prevention inline script in `static/index.html`**
Immediately after `<head>` opens, before the stylesheet `<link>`:
```html
<script>
(function(){
var t=localStorage.getItem('hermes-theme');
if(t && t!=='dark') document.documentElement.dataset.theme=t;
})();
</script>
```
This runs synchronously before the stylesheet parses. Zero flicker.
**3. Theme loading in `static/boot.js`**
In the existing `api('/api/settings')` call, read and apply the theme:
```js
const s = await api('/api/settings');
window._sendKey = s.send_key || 'enter';
window._showTokenUsage = !!s.show_token_usage;
window._showCliSessions = !!s.show_cli_sessions;
// Theme: apply server preference, update localStorage for flicker prevention
const theme = s.theme || 'dark';
document.documentElement.dataset.theme = theme;
localStorage.setItem('hermes-theme', theme);
```
**4. Theme setting in `api/config.py`**
```python
_SETTINGS_DEFAULTS = {
...
'theme': 'dark', # active UI theme name
...
}
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
```
No enum constraint on `theme` -- allows user-defined theme names to work
without server changes.
---
### Track B: Theme picker UI
**Settings panel addition (`static/index.html` + `static/panels.js`)**
A `<select>` in the Settings panel, below the send-key picker:
```html
<div class="settings-field">
<label for="settingsTheme">Theme</label>
<select id="settingsTheme" ...>
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
</select>
</div>
```
In `loadSettingsPanel()`:
```js
const themeSel = $('settingsTheme');
if(themeSel) themeSel.value = settings.theme || 'dark';
```
In `saveSettings()`:
```js
body.theme = $('settingsTheme').value;
```
**Live preview on select change (no save required):**
```js
$('settingsTheme').addEventListener('change', e => {
document.documentElement.dataset.theme = e.target.value;
localStorage.setItem('hermes-theme', e.target.value);
});
```
This gives instant visual feedback as the user clicks through options.
The full settings save then persists it server-side.
**`/theme` slash command (`static/commands.js`)**
```js
async function cmdTheme(arg) {
const themes = ['dark','light','solarized','monokai','nord'];
if(!arg || !themes.includes(arg)) {
showToast('Usage: /theme dark|light|solarized|monokai|nord');
return;
}
document.documentElement.dataset.theme = arg;
localStorage.setItem('hermes-theme', arg);
try { await api('/api/settings', {method:'POST', body: JSON.stringify({theme: arg})}); } catch(e) {}
showToast('Theme: ' + arg);
}
```
---
### Track C: Tests
New test cases in `tests/test_sprint26.py`:
1. `GET /api/settings` returns `theme: 'dark'` by default
2. `POST /api/settings` with `{theme: 'light'}` persists and round-trips
3. `POST /api/settings` with `{theme: 'nord'}` accepts any string (no enum gate)
4. Theme value survives server restart (reads from `settings.json`)
5. `/theme` command fires without error for each named theme
6. `loadSettingsPanel()` populates the select with the current theme value
7. Settings save includes theme in the POST body
8. `data-theme` attribute is set on `<html>` before first paint (inline script)
**Estimated new tests:** 8. Target total after sprint: ~443.
---
### What's out of scope
- **Custom color editors** (hex pickers for each variable): saves that for v2.
The five shipped themes cover the main use cases. A custom theme can always
be added by dropping a CSS block with no code changes.
- **Per-session themes**: single global preference is the right call for v1.
- **System `prefers-color-scheme` sync**: nice-to-have, low priority. The
flicker-prevention script could be extended to read the media query if no
explicit preference is set.
- **Prism.js theme switching**: the code-block syntax highlighting comes from
a CDN stylesheet. Swapping it requires a `<link>` swap and SRI re-check.
Defer to a future sprint; the default Prism Tomorrow theme works on all
current dark themes and is acceptable on light.
---
**Estimated tests:** 8 new. Target total: ~443.
**Hermes CLI parity impact:** None
**Claude parity impact:** Medium (Claude.ai has light/dark/system sync)
**User-facing value:** High -- first thing many users ask for
---
*Last updated: April 5, 2026*
*Current version: v0.33 | 424 tests*
*Current version: v0.36 | 433 tests*
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
*Docs sweep policy: update markdown proactively during PR reviews and after significant releases*

View File

@@ -1,14 +1,14 @@
# Hermes Web UI: Browser Testing Plan
> This document is for manual browser testing by you or by a Claude browser agent.
> It covers user-facing features of the UI through Sprint 22 (v0.24).
> It covers user-facing features of the UI through Sprint 26 (v0.36).
> Each section is written as a step-by-step test procedure with expected outcomes.
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
>
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
>
> Automated tests: 424 total (424 passing, 0 failures)
> Automated tests: 433 total (433 passing, 0 failures)
> Run: `pytest tests/ -v --timeout=60`
---
@@ -1708,8 +1708,8 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
---
*Last updated: Sprint 22 / v0.24, April 3, 2026*
*Total automated tests: 415 (392 passing, 23 pre-existing failures)*
*Regression gate: tests/test_regressions.py (23 tests)*
*Last updated: Sprint 26 / v0.36, April 5, 2026*
*Total automated tests: 440 (440 passing, 0 failures)*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

144
THEMES.md Normal file
View File

@@ -0,0 +1,144 @@
# Hermes Web UI — Themes
Hermes Web UI supports pluggable color themes. Six themes ship built-in, and
you can create your own with pure CSS — no Python changes needed.
---
## Switching Themes
**Settings panel:** Click the gear icon, select a theme from the dropdown. The
preview is instant — the UI updates as you click through options.
**Slash command:** Type `/theme dark` or `/theme light` in the composer.
**Themes persist** across page reloads and server restarts (stored in
`settings.json` server-side, with `localStorage` for flicker-free loading).
---
## Built-in Themes
| Theme | Description |
|-------|-------------|
| **Dark** (default) | Deep navy/indigo with muted blue accents. Easy on the eyes for long sessions. |
| **Light** | Warm off-white with dark text. High contrast for bright environments. |
| **Slate** | Warm charcoal, lighter than Dark. Easier on the eyes for extended use. |
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
---
## Creating a Custom Theme
A theme is a CSS block that overrides the color variables. Add it to
`static/style.css` (or a separate file that you link after the main stylesheet).
### Step 1: Define your theme block
Every color in the UI comes from these CSS variables:
```css
:root[data-theme="your-theme-name"] {
/* Core palette */
--bg: #1a1a2e; /* Main background */
--sidebar: #16213e; /* Sidebar background */
--border: rgba(255,255,255,0.08); /* Subtle borders */
--border2: rgba(255,255,255,0.14); /* Stronger borders */
--text: #e8e8f0; /* Primary text color */
--muted: #8888aa; /* Secondary/muted text */
--accent: #e94560; /* Accent color (errors, warnings, delete) */
--blue: #7cb9ff; /* Primary action color (links, active states) */
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
--code-bg: #0d1117; /* Code block background */
/* Surface and chrome (required for full theme polish) */
--surface: #1a2535; /* Dropdowns, popups, toast, approval card */
--topbar-bg: rgba(22,33,62,.98); /* Topbar background */
--main-bg: rgba(26,26,46,0.5); /* Main chat area background */
--input-bg: rgba(255,255,255,.04); /* Input/button subtle backgrounds */
--hover-bg: rgba(255,255,255,.06); /* Hover state backgrounds */
--focus-ring: rgba(124,185,255,.35); /* Focus border color */
--focus-glow: rgba(124,185,255,.08); /* Focus box-shadow glow */
/* Typography (required for readable text across themes) */
--strong: #fff; /* Bold text in messages */
--em: #c9c9e8; /* Italic text in messages */
--code-text: #f0c27f; /* Inline code text color */
--code-inline-bg: rgba(0,0,0,.35); /* Inline code background */
--pre-text: #e2e8f0; /* Code block text color */
}
```
The **core palette** controls the overall mood. The **surface/chrome** and
**typography** variables are part of the standard theme contract — define all
of them for a complete theme.
For **light themes**, you also need `:root[data-theme="name"]` overrides
for elements that use `rgba(255,255,255,.XX)` hover/border effects (these
are invisible on light backgrounds). See the built-in light theme for the
full pattern — it overrides ~45 selectors for proper dark-on-light contrast
on hover states, borders, chips, role labels, session items, and
interactive elements.
### Step 2: Add it to the theme picker (optional)
To make your theme appear in the Settings dropdown, add an `<option>` to the
theme `<select>` in `static/index.html`:
```html
<option value="your-theme-name">Your Theme Name</option>
```
And update the `/theme` command's valid theme list in `static/commands.js`.
### Step 3: Test it
Switch to your theme via `/theme your-theme-name` or the Settings panel.
Check these areas:
- Sidebar session list (hover states, active state, project borders)
- Message bubbles (user vs assistant styling)
- Code blocks (background contrast, copy button visibility)
- Tool cards (running indicator, expand/collapse)
- Settings panel and login page
- Mobile layout (hamburger sidebar, bottom nav)
### Tips
- **Light themes** need scrollbar and selection overrides, plus the full
text/code set (`--strong`, `--em`, `--code-text`, `--code-inline-bg`,
`--pre-text`) or they will look broken.
- The **logo gradient** uses `--accent` automatically, so it adapts to your
theme without extra work.
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
It works well on dark themes; on light themes the contrast is acceptable but
not perfect. Custom Prism theme support is planned for a future update.
- **No server changes needed.** The `theme` setting in `settings.json` accepts
any string — your custom theme name will persist without code changes.
---
## How Themes Work Internally
1. Each theme is a `:root[data-theme="name"]` CSS block that overrides variables.
2. Switching themes sets `document.documentElement.dataset.theme = name` in JS.
3. A tiny inline `<script>` in `<head>` reads `localStorage` before the
stylesheet loads — this prevents a flash of the wrong theme on page load.
4. The theme preference is saved server-side via `POST /api/settings` and
loaded on boot via `GET /api/settings`.
5. The `/theme` command and Settings dropdown both update the DOM, localStorage,
and server settings simultaneously.
---
## Contributing a Theme
To contribute a new built-in theme:
1. Add your `:root[data-theme="name"]` block to `static/style.css`
2. Add the `<option>` to the Settings panel in `static/index.html`
3. Add the theme name to the valid list in `cmdTheme()` in `static/commands.js`
4. Test on desktop and mobile
5. Open a PR — themes are pure CSS additions with no backend changes needed

View File

@@ -26,17 +26,38 @@ _sessions = {}
def _signing_key():
"""Derive a stable signing key from STATE_DIR."""
return hashlib.sha256(str(STATE_DIR).encode()).digest()
"""Return a random signing key, generating and persisting one on first call."""
key_file = STATE_DIR / '.signing_key'
if key_file.exists():
try:
raw = key_file.read_bytes()
if len(raw) >= 32:
return raw[:32]
except Exception:
pass
# Generate a new random key
key = secrets.token_bytes(32)
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
key_file.write_bytes(key)
key_file.chmod(0o600)
except Exception:
pass # key works for this process even if persist fails
return key
def _hash_password(password):
"""SHA-256 hash with a salt derived from STATE_DIR."""
salt = str(STATE_DIR).encode()
return hashlib.sha256(salt + password.encode()).hexdigest()
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
Salt is the persisted random signing key, which is secret and unique per
installation. This keeps the stored hash format a plain hex string
(no format change to settings.json) while replacing the predictable
STATE_DIR-derived salt from the original implementation."""
salt = _signing_key()
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
return dk.hex()
def get_password_hash():
def get_password_hash() -> str | None:
"""Return the active password hash, or None if auth is disabled.
Priority: env var > settings.json."""
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
@@ -46,12 +67,12 @@ def get_password_hash():
return settings.get('password_hash') or None
def is_auth_enabled():
def is_auth_enabled() -> bool:
"""True if a password is configured (env var or settings)."""
return get_password_hash() is not None
def verify_password(plain):
def verify_password(plain) -> bool:
"""Verify a plaintext password against the stored hash."""
expected = get_password_hash()
if not expected:
@@ -59,7 +80,7 @@ def verify_password(plain):
return hmac.compare_digest(_hash_password(plain), expected)
def create_session():
def create_session() -> str:
"""Create a new auth session. Returns signed cookie value."""
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_TTL
@@ -67,7 +88,7 @@ def create_session():
return f"{token}.{sig}"
def verify_session(cookie_value):
def verify_session(cookie_value) -> bool:
"""Verify a signed session cookie. Returns True if valid and not expired."""
if not cookie_value or '.' not in cookie_value:
return False
@@ -82,14 +103,14 @@ def verify_session(cookie_value):
return True
def invalidate_session(cookie_value):
def invalidate_session(cookie_value) -> None:
"""Remove a session token."""
if cookie_value and '.' in cookie_value:
token = cookie_value.rsplit('.', 1)[0]
_sessions.pop(token, None)
def parse_cookie(handler):
def parse_cookie(handler) -> str | None:
"""Extract the auth cookie from the request headers."""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
@@ -103,7 +124,7 @@ def parse_cookie(handler):
return morsel.value if morsel else None
def check_auth(handler, parsed):
def check_auth(handler, parsed) -> bool:
"""Check if request is authorized. Returns True if OK.
If not authorized, sends 401 (API) or 302 redirect (page) and returns False."""
if not is_auth_enabled():
@@ -128,7 +149,7 @@ def check_auth(handler, parsed):
return False
def set_auth_cookie(handler, cookie_value):
def set_auth_cookie(handler, cookie_value) -> None:
"""Set the auth cookie on the response."""
cookie = http.cookies.SimpleCookie()
cookie[COOKIE_NAME] = cookie_value
@@ -139,7 +160,7 @@ def set_auth_cookie(handler, cookie_value):
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
def clear_auth_cookie(handler):
def clear_auth_cookie(handler) -> None:
"""Clear the auth cookie on the response."""
cookie = http.cookies.SimpleCookie()
cookie[COOKIE_NAME] = ''

View File

@@ -169,7 +169,7 @@ def get_config() -> dict:
reload_config()
return _cfg_cache
def reload_config():
def reload_config() -> None:
"""Reload config.yaml from the active profile's directory."""
with _cfg_lock:
_cfg_cache.clear()
@@ -208,7 +208,7 @@ DEFAULT_WORKSPACE = _discover_default_workspace()
DEFAULT_MODEL = os.getenv('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')
# ── Startup diagnostics ───────────────────────────────────────────────────────
def print_startup_config():
def print_startup_config() -> None:
"""Print detected configuration at startup so the user can verify what was found."""
ok = '\033[32m[ok]\033[0m'
warn = '\033[33m[!!]\033[0m'
@@ -243,7 +243,7 @@ def print_startup_config():
flush=True
)
def verify_hermes_imports():
def verify_hermes_imports() -> tuple:
"""
Attempt to import the key Hermes modules.
Returns (ok: bool, missing: list[str], errors: dict[str, str]).
@@ -366,7 +366,7 @@ _PROVIDER_MODELS = {
}
def resolve_model_provider(model_id: str):
def resolve_model_provider(model_id: str) -> tuple:
"""Resolve bare model name, provider, and base_url for AIAgent.
Model IDs from the dropdown may include a provider prefix
@@ -391,6 +391,10 @@ def resolve_model_provider(model_id: str):
if '/' in model_id:
prefix, bare = model_id.split('/', 1)
# OpenRouter always needs the full provider/model path (e.g. openrouter/free,
# anthropic/claude-sonnet-4.6). Never strip the prefix for OpenRouter.
if config_provider == 'openrouter':
return model_id, 'openrouter', config_base_url
# If prefix matches config provider exactly, strip it and use that provider directly.
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
if config_provider and prefix == config_provider:
@@ -426,7 +430,9 @@ def get_available_models() -> dict:
groups = []
# 1. Read config.yaml model section
cfg_base_url = '' # must be defined before conditional blocks (#117)
model_cfg = cfg.get('model', {})
cfg_base_url = ''
if isinstance(model_cfg, str):
default_model = model_cfg
elif isinstance(model_cfg, dict):
@@ -606,6 +612,25 @@ def get_available_models() -> dict:
for provider_name, models in by_provider.items():
groups.append({'provider': provider_name, 'models': models})
# Ensure the user's configured default_model always appears in the dropdown.
# It may be missing if the model isn't in any hardcoded list (e.g. openrouter/free,
# a custom local model, or any model.default not in _FALLBACK_MODELS).
if default_model:
all_ids = {m['id'] for g in groups for m in g.get('models', [])}
if default_model not in all_ids:
# Determine which group to inject into
label = default_model.split('/')[-1] if '/' in default_model else default_model
injected = False
for g in groups:
if active_provider and active_provider.lower() in g.get('provider', '').lower():
g['models'].insert(0, {'id': default_model, 'label': label})
injected = True
break
if not injected and groups:
groups[0]['models'].insert(0, {'id': default_model, 'label': label})
elif not groups:
groups.append({'provider': active_provider or 'Default', 'models': [{'id': default_model, 'label': label}]})
return {
'active_provider': active_provider,
'default_model': default_model,
@@ -653,6 +678,8 @@ _SETTINGS_DEFAULTS = {
'show_token_usage': False, # show input/output token badge below assistant messages
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
'check_for_updates': True, # check if webui/agent repos are behind upstream
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
'password_hash': None, # SHA-256 hash; None = auth disabled
}
@@ -672,7 +699,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
_SETTINGS_ENUM_VALUES = {
'send_key': {'enter', 'ctrl+enter'},
}
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'}
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates'}
def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""

View File

@@ -6,14 +6,14 @@ from pathlib import Path
from api.config import IMAGE_EXTS, MD_EXTS
def require(body: dict, *fields):
def require(body: dict, *fields) -> None:
"""Phase D: Validate required fields. Raises ValueError with clean message."""
missing = [f for f in fields if not body.get(f) and body.get(f) != 0]
if missing:
raise ValueError(f"Missing required field(s): {', '.join(missing)}")
def bad(handler, msg, status=400):
def bad(handler, msg, status: int=400):
"""Return a clean JSON error response."""
return j(handler, {'error': msg}, status=status)
@@ -32,7 +32,7 @@ def _security_headers(handler):
handler.send_header('Referrer-Policy', 'same-origin')
def j(handler, payload, status=200):
def j(handler, payload, status: int=200) -> None:
"""Send a JSON response."""
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
handler.send_response(status)
@@ -44,7 +44,7 @@ def j(handler, payload, status=200):
handler.wfile.write(body)
def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
"""Send a plain text or HTML response."""
body = payload if isinstance(payload, bytes) else str(payload).encode('utf-8')
handler.send_response(status)
@@ -59,7 +59,7 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'):
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
def read_body(handler):
def read_body(handler) -> dict:
"""Read and JSON-parse a POST request body (capped at 20MB)."""
length = int(handler.headers.get('Content-Length', 0))
if length > MAX_BODY_BYTES:

View File

@@ -34,12 +34,12 @@ def _write_session_index():
class Session:
def __init__(self, session_id=None, title='Untitled',
def __init__(self, session_id: str=None, title: str='Untitled',
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
messages=None, created_at=None, updated_at=None,
tool_calls=None, pinned=False, archived=False,
project_id=None, profile=None,
input_tokens=0, output_tokens=0, estimated_cost=None,
tool_calls=None, pinned: bool=False, archived: bool=False,
project_id: str=None, profile=None,
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -61,7 +61,7 @@ class Session:
def path(self):
return SESSION_DIR / f'{self.session_id}.json'
def save(self):
def save(self) -> None:
self.updated_at = time.time()
self.path.write_text(
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
@@ -76,7 +76,7 @@ class Session:
return None
return cls(**json.loads(p.read_text(encoding='utf-8')))
def compact(self):
def compact(self) -> dict:
return {
'session_id': self.session_id,
'title': self.title,
@@ -165,7 +165,7 @@ def all_sessions():
return result
def title_from(messages, fallback='Untitled'):
def title_from(messages, fallback: str='Untitled'):
"""Derive a session title from the first user message."""
for m in messages:
if m.get('role') == 'user':
@@ -180,7 +180,7 @@ def title_from(messages, fallback='Untitled'):
# ── Project helpers ──────────────────────────────────────────────────────────
def load_projects():
def load_projects() -> list:
"""Load project list from disk. Returns list of project dicts."""
if not PROJECTS_FILE.exists():
return []
@@ -189,12 +189,12 @@ def load_projects():
except Exception:
return []
def save_projects(projects):
def save_projects(projects) -> None:
"""Write project list to disk."""
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
def import_cli_session(session_id, title, messages, model='unknown', profile=None):
def import_cli_session(session_id: str, title: str, messages, model: str='unknown', profile=None):
"""Create a new WebUI session populated with CLI messages.
Returns the Session object.
"""
@@ -212,7 +212,7 @@ def import_cli_session(session_id, title, messages, model='unknown', profile=Non
# ── CLI session bridge ──────────────────────────────────────────────────────
def get_cli_sessions():
def get_cli_sessions() -> list:
"""Read CLI sessions from the agent's SQLite store and return them as
dicts in a format the WebUI sidebar can render alongside local sessions.
@@ -296,7 +296,7 @@ def get_cli_sessions():
return cli_sessions
def get_cli_session_messages(sid):
def get_cli_session_messages(sid) -> list:
"""Read messages for a single CLI session from the SQLite store.
Returns a list of {role, content, timestamp} dicts.
Returns empty list on any error.
@@ -338,7 +338,7 @@ def get_cli_session_messages(sid):
return msgs
def delete_cli_session(sid):
def delete_cli_session(sid) -> bool:
"""Delete a CLI session from state.db (messages + session row).
Returns True if deleted, False if not found or error.
"""

View File

@@ -137,7 +137,7 @@ def _reload_dotenv(home: Path):
pass
def init_profile_state():
def init_profile_state() -> None:
"""Initialize profile state at server startup.
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches

View File

@@ -82,8 +82,9 @@ button:hover{background:rgba(124,185,255,.25)}
<div class="logo">H</div>
<h1>Hermes</h1>
<p class="sub">Enter your password to continue</p>
<form onsubmit="return doLogin(event)">
<input type="password" id="pw" placeholder="Password" autofocus>
<form onsubmit="doLogin(event);return false">
<input type="password" id="pw" placeholder="Password" autofocus
onkeydown="if(event.key==='Enter'){doLogin(event);event.preventDefault();}">
<button type="submit">Sign in</button>
</form>
<div class="err" id="err"></div>
@@ -107,7 +108,7 @@ async function doLogin(e){
# ── GET routes ────────────────────────────────────────────────────────────────
def handle_get(handler, parsed):
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
if parsed.path in ('/', '/index.html'):
@@ -227,6 +228,22 @@ def handle_get(handler, parsed):
info = git_info_for_workspace(Path(s.workspace))
return j(handler, {'git': info})
if parsed.path == '/api/updates/check':
settings = load_settings()
if not settings.get('check_for_updates', True):
return j(handler, {'disabled': True})
qs = parse_qs(parsed.query)
force = qs.get('force', ['0'])[0] == '1'
# ?simulate=1 returns fake behind counts for UI testing (localhost only)
if qs.get('simulate', ['0'])[0] == '1' and handler.client_address[0] == '127.0.0.1':
return j(handler, {
'webui': {'name': 'webui', 'behind': 3, 'current_sha': 'abc1234', 'latest_sha': 'def5678', 'branch': 'master'},
'agent': {'name': 'agent', 'behind': 1, 'current_sha': 'aaa0001', 'latest_sha': 'bbb0002', 'branch': 'master'},
'checked_at': 0,
})
from api.updates import check_for_updates
return j(handler, check_for_updates(force=force))
if parsed.path == '/api/chat/stream/status':
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
@@ -318,7 +335,7 @@ def handle_get(handler, parsed):
# ── POST routes ───────────────────────────────────────────────────────────────
def handle_post(handler, parsed):
def handle_post(handler, parsed) -> bool:
"""Handle all POST routes. Returns True if handled, False for 404."""
if parsed.path == '/api/upload':
@@ -600,6 +617,14 @@ def handle_post(handler, parsed):
if parsed.path == '/api/session/import':
return _handle_session_import(handler, body)
# ── Self-update (POST) ──
if parsed.path == '/api/updates/apply':
target = body.get('target', '')
if target not in ('webui', 'agent'):
return bad(handler, 'target must be "webui" or "agent"')
from api.updates import apply_update
return j(handler, apply_update(target))
# ── CLI session import (POST) ──
if parsed.path == '/api/session/import_cli':
return _handle_session_import_cli(handler, body)

View File

@@ -43,7 +43,7 @@ def _get_state_db():
return None
def sync_session_start(session_id, model=None):
def sync_session_start(session_id: str, model=None) -> None:
"""Register a WebUI session in state.db (idempotent).
Called when a session's first message is sent.
"""
@@ -65,8 +65,8 @@ def sync_session_start(session_id, model=None):
pass
def sync_session_usage(session_id, input_tokens=0, output_tokens=0,
estimated_cost=None, model=None, title=None):
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
estimated_cost=None, model=None, title: str=None) -> None:
"""Update token usage and title for a WebUI session in state.db.
Called after each turn completes. Uses absolute=True to set totals
(the WebUI Session already accumulates across turns).

View File

@@ -17,6 +17,12 @@ from api.config import (
resolve_model_provider,
)
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
# interleave their os.environ writes. This global lock serializes the env
# save/restore around the entire agent run.
_ENV_LOCK = threading.Lock()
# Lazy import to avoid circular deps -- hermes-agent is on sys.path via api/config.py
try:
from run_agent import AIAgent
@@ -101,7 +107,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
HERMES_HOME=_profile_home,
)
# Still set process-level env as fallback for tools that bypass thread-local
with _agent_lock:
with _ENV_LOCK:
old_cwd = os.environ.get('TERMINAL_CWD')
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')

164
api/updates.py Normal file
View File

@@ -0,0 +1,164 @@
"""
Hermes Web UI -- Self-update checker.
Checks if the webui and hermes-agent git repos are behind their upstream
branches. Results are cached server-side (30-min TTL) so git fetch runs
at most twice per hour regardless of client count.
Skips repos that are not git checkouts (e.g. Docker baked images where
.git does not exist).
"""
import subprocess
import threading
import time
from pathlib import Path
from api.config import REPO_ROOT
# Lazy -- may be None if agent not found
try:
from api.config import _AGENT_DIR
except ImportError:
_AGENT_DIR = None
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
_cache_lock = threading.Lock()
_check_in_progress = False
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
CACHE_TTL = 1800 # 30 minutes
def _run_git(args, cwd, timeout=10):
"""Run a git command and return (stdout, ok)."""
try:
r = subprocess.run(
['git'] + args, cwd=str(cwd), capture_output=True,
text=True, timeout=timeout,
)
return r.stdout.strip(), r.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return '', False
def _detect_default_branch(path):
"""Detect the remote default branch (master or main)."""
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
if ok and out:
# refs/remotes/origin/master -> master
return out.split('/')[-1]
# Fallback: try master, then main
for branch in ('master', 'main'):
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
if ok:
return branch
return 'master'
def _check_repo(path, name):
"""Check if a git repo is behind its upstream. Returns dict or None."""
if path is None or not (path / '.git').exists():
return None
# Fetch latest from origin (network call, cached by TTL)
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
branch = _detect_default_branch(path)
# Count commits behind
out, ok = _run_git(['rev-list', '--count', f'HEAD..origin/{branch}'], path)
behind = int(out) if ok and out.isdigit() else 0
# Get short SHAs for display
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
latest, _ = _run_git(['rev-parse', '--short', f'origin/{branch}'], path)
return {
'name': name,
'behind': behind,
'current_sha': current,
'latest_sha': latest,
'branch': branch,
}
def check_for_updates(force=False):
"""Return cached update status for webui and agent repos."""
global _check_in_progress
with _cache_lock:
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
return dict(_update_cache)
if _check_in_progress:
return dict(_update_cache) # another thread is already checking
_check_in_progress = True
try:
# Run checks outside the lock (network I/O)
webui_info = _check_repo(REPO_ROOT, 'webui')
agent_info = _check_repo(_AGENT_DIR, 'agent')
with _cache_lock:
_update_cache['webui'] = webui_info
_update_cache['agent'] = agent_info
_update_cache['checked_at'] = time.time()
return dict(_update_cache)
finally:
_check_in_progress = False
def apply_update(target):
"""Stash, pull --ff-only, pop for the given target repo."""
if not _apply_lock.acquire(blocking=False):
return {'ok': False, 'message': 'Update already in progress'}
try:
return _apply_update_inner(target)
finally:
_apply_lock.release()
def _apply_update_inner(target):
"""Inner implementation of apply_update, called under _apply_lock."""
if target == 'webui':
path = REPO_ROOT
elif target == 'agent':
path = _AGENT_DIR
else:
return {'ok': False, 'message': f'Unknown target: {target}'}
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
branch = _detect_default_branch(path)
# Check for dirty working tree
status_out, _ = _run_git(['status', '--porcelain'], path)
stashed = False
if status_out:
_, ok = _run_git(['stash'], path)
if not ok:
return {'ok': False, 'message': 'Failed to stash local changes'}
stashed = True
# Pull with ff-only (no merge commits)
pull_out, pull_ok = _run_git(['pull', '--ff-only', 'origin', branch], path, timeout=30)
if not pull_ok:
if stashed:
_run_git(['stash', 'pop'], path)
return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'}
# Pop stash if we stashed
if stashed:
_, pop_ok = _run_git(['stash', 'pop'], path)
if not pop_ok:
return {
'ok': False,
'message': 'Updated but stash pop failed -- manual merge needed',
'stash_conflict': True,
}
# Invalidate cache
with _cache_lock:
_update_cache['checked_at'] = 0
return {'ok': True, 'message': f'{target} updated successfully', 'target': target}

View File

@@ -11,7 +11,7 @@ from api.models import get_session
from api.workspace import safe_resolve_ws
def parse_multipart(rfile, content_type, content_length):
def parse_multipart(rfile, content_type, content_length) -> tuple:
import re as _re, email.parser as _ep
m = _re.search(r'boundary=([^;\s]+)', content_type)
if not m:
@@ -70,7 +70,11 @@ def handle_upload(handler):
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
safe_name = _re.sub(r'[^\w.\-]', '_', Path(filename).name)[:200]
dest = workspace / safe_name
# Reject names that are purely dots (path traversal: ".." survives regex)
if not safe_name or safe_name.strip('.') == '':
return j(handler, {'error': 'Invalid filename'}, status=400)
# Verify the resolved path stays within the workspace
dest = safe_resolve_ws(workspace, safe_name)
dest.write_bytes(file_bytes)
return j(handler, {'filename': safe_name, 'path': str(dest), 'size': dest.stat().st_size})
except Exception as e:

View File

@@ -176,7 +176,7 @@ def load_workspaces() -> list:
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
def save_workspaces(workspaces: list):
def save_workspaces(workspaces: list) -> None:
ws_file = _workspaces_file()
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
@@ -202,7 +202,7 @@ def get_last_workspace() -> str:
return _profile_default_workspace()
def set_last_workspace(path: str):
def set_last_workspace(path: str) -> None:
try:
lw_file = _last_workspace_file()
lw_file.parent.mkdir(parents=True, exist_ok=True)
@@ -218,7 +218,7 @@ def safe_resolve_ws(root: Path, requested: str) -> Path:
return resolved
def list_dir(workspace: Path, rel='.'):
def list_dir(workspace: Path, rel: str='.'):
target = safe_resolve_ws(workspace, rel)
if not target.is_dir():
raise FileNotFoundError(f"Not a directory: {rel}")
@@ -235,7 +235,7 @@ def list_dir(workspace: Path, rel='.'):
return entries
def read_file_content(workspace: Path, rel: str):
def read_file_content(workspace: Path, rel: str) -> dict:
target = safe_resolve_ws(workspace, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")

View File

@@ -18,7 +18,7 @@ class Handler(BaseHTTPRequestHandler):
server_version = 'HermesWebUI/0.2'
def log_message(self, fmt, *args): pass # suppress default Apache-style log
def log_request(self, code='-', size='-'):
def log_request(self, code: str='-', size: str='-') -> None:
"""Structured JSON logs for each request."""
import json as _json
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
@@ -31,7 +31,7 @@ class Handler(BaseHTTPRequestHandler):
})
print(f'[webui] {record}', flush=True)
def do_GET(self):
def do_GET(self) -> None:
self._req_t0 = time.time()
try:
parsed = urlparse(self.path)
@@ -43,7 +43,7 @@ class Handler(BaseHTTPRequestHandler):
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
return j(self, {'error': 'Internal server error'}, status=500)
def do_POST(self):
def do_POST(self) -> None:
self._req_t0 = time.time()
try:
parsed = urlparse(self.path)
@@ -56,7 +56,7 @@ class Handler(BaseHTTPRequestHandler):
return j(self, {'error': 'Internal server error'}, status=500)
def main():
def main() -> None:
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
print_startup_config()

View File

@@ -226,7 +226,7 @@ document.addEventListener('keydown',async e=>{
if(e.key==='Escape'){
// Close settings overlay if open
const settingsOverlay=$('settingsOverlay');
if(settingsOverlay&&settingsOverlay.style.display!=='none'){toggleSettings();return;}
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;}
// Close workspace dropdown
closeWsDropdown();
// Clear session search
@@ -308,7 +308,15 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
(async()=>{
// Load send key preference
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
let _bootSettings={};
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;_bootSettings={check_for_updates:false};}
// Non-blocking update check (fire-and-forget, once per tab session)
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
}
// Fetch active profile
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
// Update profile chip label immediately

View File

@@ -10,6 +10,7 @@ const COMMANDS=[
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
{name:'new', desc:'Start a new chat session', fn:cmdNew},
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
{name:'theme', desc:'Switch theme (dark/light/slate/solarized/monokai/nord)', fn:cmdTheme, arg:'name'},
];
function parseCommand(text){
@@ -122,6 +123,22 @@ async function cmdUsage(){
showToast('Token usage '+(next?'on':'off'));
}
async function cmdTheme(args){
const themes=['dark','light','slate','solarized','monokai','nord'];
if(!args||!themes.includes(args.toLowerCase())){
showToast('Usage: /theme '+themes.join('|'));
return;
}
const t=args.toLowerCase();
document.documentElement.dataset.theme=t;
localStorage.setItem('hermes-theme',t);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:t})});}catch(e){}
// Update settings dropdown if panel is open
const sel=$('settingsTheme');
if(sel)sel.value=t;
showToast('Theme: '+t);
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx=-1;

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<link rel="stylesheet" href="/static/style.css">
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
@@ -13,7 +14,7 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.33</div></div></div>
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.36.2</div></div></div>
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
@@ -202,6 +203,13 @@
<div class="messages-inner" id="msgInner"></div>
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
</div>
<div class="update-banner" id="updateBanner">
<span id="updateMsg"></span>
<div style="display:flex;gap:8px;flex-shrink:0">
<button class="update-btn" onclick="dismissUpdate()">Later</button>
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
</div>
</div>
<div class="reconnect-banner" id="reconnectBanner">
<span id="reconnectMsg">&#9888; A response may have been in progress when you last left. Reload messages?</span>
<div style="display:flex;gap:8px;flex-shrink:0">
@@ -311,17 +319,13 @@
<div class="settings-panel">
<div class="settings-header">
<h3 style="margin:0;font-size:16px">Settings</h3>
<button class="panel-icon-btn" onclick="toggleSettings()" title="Close">&#10005;</button>
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close">&#10005;</button>
</div>
<div class="settings-body">
<div class="settings-field">
<label for="settingsModel">Default Model</label>
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label for="settingsWorkspace">Default Workspace</label>
<select id="settingsWorkspace" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label for="settingsSendKey">Send Key</label>
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
@@ -329,6 +333,17 @@
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<div class="settings-field">
<label for="settingsTheme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="slate">Slate (charcoal)</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
</select>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
@@ -350,6 +365,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
Check for updates
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>

View File

@@ -908,17 +908,70 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
// ── Settings panel ───────────────────────────────────────────────────────────
let _settingsDirty = false;
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
function toggleSettings(){
const overlay=$('settingsOverlay');
if(!overlay) return;
if(overlay.style.display==='none'){
_settingsDirty = false;
_settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark';
overlay.style.display='';
loadSettingsPanel();
} else {
overlay.style.display='none';
_closeSettingsPanel();
}
}
// Close with unsaved-changes check. If dirty, show a confirm dialog.
function _closeSettingsPanel(){
if(!_settingsDirty){
// Nothing changed -- revert any live preview and close
_revertSettingsPreview();
$('settingsOverlay').style.display='none';
return;
}
// Dirty -- show inline confirm bar
_showSettingsUnsavedBar();
}
// Revert live DOM/localStorage to what they were when the panel opened
function _revertSettingsPreview(){
if(_settingsThemeOnOpen){
document.documentElement.dataset.theme = _settingsThemeOnOpen;
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
}
}
// Show the "Unsaved changes" bar inside the settings panel
function _showSettingsUnsavedBar(){
let bar = $('settingsUnsavedBar');
if(bar){ bar.style.display=''; return; }
// Create it
bar = document.createElement('div');
bar.id = 'settingsUnsavedBar';
bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;';
bar.innerHTML = '<span style="color:var(--text)">You have unsaved changes.</span>'
+ '<span style="display:flex;gap:8px">'
+ '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>'
+ '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save</button>'
+ '</span>';
const body = document.querySelector('.settings-body') || document.querySelector('.settings-panel');
if(body) body.prepend(bar);
}
function _discardSettings(){
_revertSettingsPreview();
_settingsDirty = false;
$('settingsOverlay').style.display = 'none';
}
// Mark settings as dirty whenever anything changes
function _markSettingsDirty(){
_settingsDirty = true;
}
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
@@ -940,33 +993,25 @@ async function loadSettingsPanel(){
}
}catch(e){}
modelSel.value=settings.default_model||'';
}
// Populate workspace dropdown from /api/workspaces
const wsSel=$('settingsWorkspace');
if(wsSel){
wsSel.innerHTML='';
try{
const wsData=await api('/api/workspaces');
for(const w of (wsData.workspaces||[])){
const opt=document.createElement('option');
opt.value=w.path;opt.textContent=w.name||w.path;
wsSel.appendChild(opt);
}
}catch(e){}
wsSel.value=settings.default_workspace||'';
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
}
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
// Theme preference
const themeSel=$('settingsTheme');
if(themeSel){themeSel.value=settings.theme||'dark';themeSel.addEventListener('change',_markSettingsDirty,{once:false});}
const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
const showCliCb=$('settingsShowCliSessions');
if(showCliCb) showCliCb.checked=!!settings.show_cli_sessions;
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
const syncCb=$('settingsSyncInsights');
if(syncCb) syncCb.checked=!!settings.sync_to_insights;
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
const updateCb=$('settingsCheckUpdates');
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
// Password field: always blank (we don't send hash back)
const pwField=$('settingsPassword');
if(pwField) pwField.value='';
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
// Show auth buttons only when auth is active
try{
const authStatus=await api('/api/auth/status');
@@ -981,20 +1026,22 @@ async function loadSettingsPanel(){
}
}
async function saveSettings(){
async function saveSettings(andClose){
const model=($('settingsModel')||{}).value;
const workspace=($('settingsWorkspace')||{}).value;
const sendKey=($('settingsSendKey')||{}).value;
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
const body={};
if(model) body.default_model=model;
if(workspace) body.default_workspace=workspace;
if(sendKey) body.send_key=sendKey;
body.theme=theme;
body.show_token_usage=showTokenUsage;
body.show_cli_sessions=showCliSessions;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
// Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){
try{
@@ -1002,7 +1049,9 @@ async function saveSettings(){
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
showToast('Settings saved (password set — login now required)');
toggleSettings();
_settingsDirty=false; _settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
$('settingsOverlay').style.display='none';
return;
}catch(e){showToast('Save failed: '+e.message);return;}
}
@@ -1011,10 +1060,12 @@ async function saveSettings(){
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showCliSessions=showCliSessions;
_settingsDirty=false; _settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
renderMessages();
if(typeof renderSessionList==='function') renderSessionList();
showToast('Settings saved');
toggleSettings();
$('settingsOverlay').style.display='none';
}catch(e){
showToast('Save failed: '+e.message);
}
@@ -1044,10 +1095,10 @@ async function disableAuth(){
}
}
// Close settings on overlay click (not panel click)
// Close settings on overlay click (not panel click) -- with unsaved-changes check
document.addEventListener('click',e=>{
const overlay=$('settingsOverlay');
if(overlay&&e.target===overlay) toggleSettings();
if(overlay&&e.target===overlay) _closeSettingsPanel();
});
// ── Cron completion alerts ────────────────────────────────────────────────────

View File

@@ -2,8 +2,104 @@
:root {
--bg:#1a1a2e;--sidebar:#16213e;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.14);
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
--surface:#1a2535;--topbar-bg:rgba(22,33,62,.98);--main-bg:rgba(26,26,46,0.5);
--focus-ring:rgba(124,185,255,.35);--focus-glow:rgba(124,185,255,.08);
--input-bg:rgba(255,255,255,.04);--hover-bg:rgba(255,255,255,.06);
--strong:#fff;--em:#c9c9e8;--code-text:#f0c27f;--code-inline-bg:rgba(0,0,0,.35);--pre-text:#e2e8f0;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
}
/* ── Slate theme ── */
:root[data-theme="slate"]{
--bg:#2b2d30;--sidebar:#25272b;--border:rgba(255,255,255,0.09);--border2:rgba(255,255,255,0.16);
--text:#d4d4d8;--muted:#8a8a9a;--accent:#e06c75;--blue:#82aaff;--gold:#d4a85a;--code-bg:#1e2023;
--surface:#2f3134;--topbar-bg:rgba(37,39,43,.98);--main-bg:rgba(43,45,48,0.5);
--focus-ring:rgba(130,170,255,.35);--focus-glow:rgba(130,170,255,.08);
--strong:#f0f0f3;--em:#b0b0c0;--code-text:#dca06a;--code-inline-bg:rgba(0,0,0,.3);--pre-text:#d0d0d6;
}
/* ── Light theme ── */
:root[data-theme="light"]{
--bg:#f0ede8;--sidebar:#e4e0d8;--border:rgba(0,0,0,0.09);--border2:rgba(0,0,0,0.15);
--text:#2c2825;--muted:#7a746a;--accent:#b5451b;--blue:#2d6fa3;--gold:#8a6520;--code-bg:#ddd8d0;
--surface:#e0dcd4;--topbar-bg:rgba(228,224,216,.98);--main-bg:rgba(240,237,232,0.5);
--focus-ring:rgba(45,111,163,.35);--focus-glow:rgba(45,111,163,.1);
--input-bg:rgba(0,0,0,.03);--hover-bg:rgba(0,0,0,.05);
--strong:#1a1715;--em:#5a544a;--code-text:#8b4513;--code-inline-bg:rgba(0,0,0,.06);--pre-text:#2c2825;
}
:root[data-theme="light"] ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);}
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3);}
:root[data-theme="light"] ::selection{background:rgba(45,111,163,.2);}
:root[data-theme="light"] *{scrollbar-color:rgba(0,0,0,.15) transparent;}
:root[data-theme="light"] .settings-overlay{background:rgba(0,0,0,.3);}
/* ── Light theme: sidebar, roles, chips, active states ── */
:root[data-theme="light"] .session-item{color:#5a544a;}
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;border-left-color:#2d6fa3;}
:root[data-theme="light"] .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(228,224,216,.95) 12px);}
:root[data-theme="light"] .session-pin-indicator{color:#996b15;}
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
:root[data-theme="light"] .session-actions .act-pin.pinned{color:#996b15;}
:root[data-theme="light"] .msg-role.user{color:#2d6fa3;}
:root[data-theme="light"] .msg-role.assistant{color:#8a6520;}
:root[data-theme="light"] .role-icon.user{background:rgba(45,111,163,.12);color:#2d6fa3;border-color:rgba(45,111,163,.25);}
:root[data-theme="light"] .role-icon.assistant{background:rgba(138,101,32,.12);color:#8a6520;border-color:rgba(138,101,32,.25);}
:root[data-theme="light"] .project-chip{border-color:rgba(0,0,0,.12);background:rgba(0,0,0,.04);}
:root[data-theme="light"] .project-chip:hover{background:rgba(0,0,0,.08);color:#2c2825;}
:root[data-theme="light"] .project-chip.active{background:rgba(45,111,163,.1);color:#1a5a8a;border-color:rgba(45,111,163,.3);}
:root[data-theme="light"] .chip{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.04);}
:root[data-theme="light"] .chip.model{color:#2d6fa3;border-color:rgba(45,111,163,.3);background:rgba(45,111,163,.08);}
:root[data-theme="light"] .new-chat-btn{border-color:rgba(45,111,163,.25);color:#2d6fa3;}
:root[data-theme="light"] .new-chat-btn:hover{background:rgba(45,111,163,.08);}
:root[data-theme="light"] .session-search input{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.03);}
:root[data-theme="light"] .session-search input:focus{border-color:rgba(45,111,163,.4);background:rgba(0,0,0,.02);}
:root[data-theme="light"] .cron-item{border-color:rgba(0,0,0,.08);background:rgba(0,0,0,.02);}
:root[data-theme="light"] .sm-btn{border-color:rgba(0,0,0,.1);}
:root[data-theme="light"] .sm-btn:hover{background:rgba(0,0,0,.06);border-color:rgba(0,0,0,.15);}
:root[data-theme="light"] select{border-color:rgba(0,0,0,.12);}
:root[data-theme="light"] .composer-box{border-color:rgba(0,0,0,.12);}
:root[data-theme="light"] .composer-box:focus-within{border-color:rgba(45,111,163,.5);box-shadow:0 0 0 3px rgba(45,111,163,.08);}
:root[data-theme="light"] .suggestion{border-color:rgba(0,0,0,.08);}
:root[data-theme="light"] .suggestion:hover{background:rgba(45,111,163,.06);border-color:rgba(45,111,163,.2);}
:root[data-theme="light"] .tool-card{border-color:rgba(0,0,0,.08);}
:root[data-theme="light"] .tool-card:hover{border-color:rgba(0,0,0,.15);}
:root[data-theme="light"] .icon-btn:hover{background:rgba(0,0,0,.06);}
:root[data-theme="light"] .panel-icon-btn:hover{background:rgba(0,0,0,.06);}
:root[data-theme="light"] .file-item:hover{background:rgba(0,0,0,.04);}
:root[data-theme="light"] .preview-md th{background:rgba(0,0,0,.04);}
:root[data-theme="light"] .preview-md td{border-color:rgba(0,0,0,.08);}
:root[data-theme="light"] .preview-badge.code{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .ctx-bar-wrap{background:rgba(0,0,0,.08);}
:root[data-theme="light"] .ws-opt:hover{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .profile-opt:hover{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .profile-opt.active{background:rgba(45,111,163,.06);}
:root[data-theme="light"] .profile-chip{color:#7a5a90!important;}
:root[data-theme="light"] .nav-tab:hover::after{background:var(--surface);border-color:rgba(45,111,163,.25);color:#2d6fa3;}
:root[data-theme="light"] .cron-status.disabled{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .cron-btn{background:rgba(0,0,0,.04);}
:root[data-theme="light"] .cron-btn:hover{background:rgba(0,0,0,.08);}
/* ── Solarized Dark theme ── */
:root[data-theme="solarized"]{
--bg:#002b36;--sidebar:#073642;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.13);
--text:#839496;--muted:#657b83;--accent:#dc322f;--blue:#268bd2;--gold:#b58900;--code-bg:#073642;
--surface:#0a3c48;--topbar-bg:rgba(7,54,66,.98);--main-bg:rgba(0,43,54,0.5);
--focus-ring:rgba(38,139,210,.35);--focus-glow:rgba(38,139,210,.08);
--strong:#fdf6e3;--em:#93a1a1;--code-text:#cb4b16;--code-inline-bg:rgba(0,0,0,.25);--pre-text:#93a1a1;
}
/* ── Monokai theme ── */
:root[data-theme="monokai"]{
--bg:#272822;--sidebar:#1e1f1c;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
--text:#f8f8f2;--muted:#75715e;--accent:#f92672;--blue:#66d9e8;--gold:#e6db74;--code-bg:#1e1f1c;
--surface:#2d2e28;--topbar-bg:rgba(30,31,28,.98);--main-bg:rgba(39,40,34,0.5);
--focus-ring:rgba(102,217,232,.35);--focus-glow:rgba(102,217,232,.08);
--strong:#f8f8f0;--em:#a6a28c;--code-text:#e6db74;--code-inline-bg:rgba(0,0,0,.3);--pre-text:#f8f8f2;
}
/* ── Nord theme ── */
:root[data-theme="nord"]{
--bg:#2e3440;--sidebar:#272c36;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
--text:#eceff4;--muted:#9099aa;--accent:#bf616a;--blue:#81a1c1;--gold:#ebcb8b;--code-bg:#272c36;
--surface:#333a47;--topbar-bg:rgba(39,44,54,.98);--main-bg:rgba(46,52,64,0.5);
--focus-ring:rgba(129,161,193,.35);--focus-glow:rgba(129,161,193,.08);
--strong:#eceff4;--em:#b8c0cc;--code-text:#a3be8c;--code-inline-bg:rgba(0,0,0,.2);--pre-text:#d8dee9;
}
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
@@ -15,13 +111,13 @@
.new-chat-btn:hover{background:rgba(124,185,255,0.13);border-color:rgba(124,185,255,.3);}
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;}
.session-search{padding:4px 10px 8px;flex-shrink:0;}
.session-search input{width:100%;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;}
.session-search input:focus{border-color:rgba(124,185,255,.35);background:rgba(255,255,255,.06);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
.session-search input{width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;}
.session-search input:focus{border-color:rgba(124,185,255,.35);background:var(--hover-bg);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
.session-search input::placeholder{color:var(--muted);opacity:.7;}
/* Inline session title edit */
.session-title-input{flex:1;background:rgba(20,32,60,.9);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
.session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
.session-item{padding:8px 10px 8px 8px;border-radius:0 8px 8px 0;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;position:relative;}
.session-item:hover{background:rgba(255,255,255,0.06);color:var(--text);}
.session-item:hover{background:var(--hover-bg);color:var(--text);}
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;border-left:2px solid #e8a030;padding-left:8px;}
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* ── Session action button overlay ── */
@@ -43,21 +139,28 @@
.session-date-header.pinned{color:#f5c542;}
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
.session-date-caret.collapsed{transform:rotate(-90deg);}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(20,30,50,.95);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--surface);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
.reconnect-banner{display:none;background:#1a2535;border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
.reconnect-banner.visible{display:flex;}
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
/* ── Update banner ── */
.update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;}
.update-banner.visible{display:flex;}
.update-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;}
.update-btn:hover{background:rgba(124,185,255,0.2);}
.update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);}
.update-btn:disabled{opacity:0.5;cursor:not-allowed;}
/* ── Approval card ── */
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
.approval-card.visible{display:block;}
.approval-inner{background:rgba(20,30,50,.95);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;}
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;}
.approval-cmd{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:#e2e8f0;white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;}
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,0.06);color:var(--text);cursor:pointer;transition:all .15s;}
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;}
.approval-btn:hover{background:rgba(255,255,255,0.12);}
.approval-btn.once{border-color:rgba(124,185,255,0.5);color:var(--blue);}
.approval-btn.once:hover{background:rgba(124,185,255,0.15);}
@@ -69,7 +172,7 @@
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
.nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;}
.nav-tab:hover{color:var(--text);}
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:rgba(15,22,40,.98);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
.nav-tab.active{color:var(--blue);}
.nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--blue);border-radius:2px 2px 0 0;}
/* Panel content areas (swapped by tab) */
@@ -77,7 +180,7 @@
.panel-view.active{display:flex;}
/* Cron panel */
.cron-list{flex:1;overflow-y:auto;padding:8px;}
.cron-item{border-radius:10px;border:1px solid rgba(255,255,255,.08);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
.cron-item{border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
.cron-item:hover{border-color:var(--border2);}
.cron-header{display:flex;align-items:center;gap:8px;padding:9px 11px;cursor:pointer;}
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -100,14 +203,14 @@
.cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;}
/* Skills panel */
.skills-search{padding:8px;flex-shrink:0;}
.skills-search input{width:100%;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;}
.skills-search input{width:100%;background:var(--hover-bg);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;}
.skills-search input::placeholder{color:var(--muted);}
.skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;}
.skills-category{margin-bottom:4px;}
.skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;}
.skills-cat-header:hover{color:var(--text);}
.skill-item{padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;}
.skill-item:hover{background:rgba(255,255,255,.06);color:var(--text);}
.skill-item:hover{background:var(--hover-bg);color:var(--text);}
.skill-item.active{background:rgba(124,185,255,.1);color:var(--blue);}
.skill-name{font-weight:500;flex-shrink:0;}
.skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;}
@@ -121,19 +224,19 @@
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
.sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;position:relative;z-index:10;overflow:visible;}
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
select{width:100%;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);}
optgroup{color:var(--muted);font-size:11px;font-weight:700;}
option{background:#1a1a2e;color:var(--text);padding:6px;}
option{background:var(--bg);color:var(--text);padding:6px;}
.sidebar-actions{display:flex;gap:6px;}
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:var(--input-bg);border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:rgba(26,26,46,0.5);}
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(22,33,62,.98);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:var(--main-bg);}
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;}
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,.1);color:var(--muted);font-weight:500;}
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid var(--border2);color:var(--muted);font-weight:500;}
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;}
.messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
@@ -150,11 +253,11 @@
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
.msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;}
.msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;}
.msg-body strong{color:#fff;font-weight:600;}.msg-body em{color:#c9c9e8;font-style:italic;}
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
.msg-body pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
.msg-body pre code{background:none;padding:0;border-radius:0;color:#e2e8f0;font-size:13px;line-height:1.6;}
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:rgba(255,255,255,.04);border-radius:10px 10px 0 0;border:1px solid rgba(255,255,255,.08);border-bottom:1px solid rgba(255,255,255,.05);display:flex;align-items:center;gap:6px;}
.msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;}
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:13px;line-height:1.6;}
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:var(--input-bg);border-radius:10px 10px 0 0;border:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;}
.pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;}
.pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;}
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
@@ -171,11 +274,11 @@
.empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;}
.empty-state p{font-size:14px;text-align:center;max-width:320px;}
.suggestion-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px;width:100%;max-width:380px;}
.suggestion{padding:11px 14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
.suggestion{padding:11px 14px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
.suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);}
/* ── Composer ── */
.composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
.composer-box{max-width:780px;margin:0 auto;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.12);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
.composer-box{max-width:780px;margin:0 auto;background:var(--input-bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
.composer-box:focus-within{border-color:rgba(124,185,255,0.5);box-shadow:0 0 0 3px rgba(124,185,255,0.08);}
.composer-wrap.drag-over .composer-box{border-color:var(--blue);background:rgba(124,185,255,0.06);}
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(124,185,255,0.08);border:2px dashed var(--blue);border-radius:14px;font-size:14px;color:var(--blue);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
@@ -212,12 +315,12 @@
.send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;}
.send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;}
@keyframes send-pop-in{from{opacity:0;transform:scale(.55);}to{opacity:1;transform:scale(1);}}
.upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;}
.upload-bar-wrap{display:none;height:3px;background:var(--hover-bg);border-radius:0 0 16px 16px;overflow:hidden;}
.upload-bar-wrap.active{display:block;}
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;}
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
.panel-actions{display:flex;gap:4px;}
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
@@ -231,7 +334,7 @@
.breadcrumb-bar{display:flex;align-items:center;gap:2px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border);flex-shrink:0;overflow:hidden;white-space:nowrap;}
.breadcrumb-seg{padding:1px 3px;border-radius:3px;}
.breadcrumb-link{color:var(--muted);cursor:pointer;transition:color .12s;}
.breadcrumb-link:hover{color:var(--text);background:rgba(255,255,255,.06);}
.breadcrumb-link:hover{color:var(--text);background:var(--hover-bg);}
.breadcrumb-current{color:var(--text);font-weight:500;}
.breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;}
.file-tree{flex:1;overflow-y:auto;padding:8px;}
@@ -251,15 +354,15 @@
/* Markdown rendered preview */
.preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;}
.preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;}
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:#fff;border-bottom:1px solid var(--border);padding-bottom:6px;}
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:#fff;}
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:var(--strong);border-bottom:1px solid var(--border);padding-bottom:6px;}
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:var(--strong);}
.preview-md h3{font-size:13px;font-weight:600;margin:12px 0 4px;color:#e8e8f0;}
.preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;}
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
.preview-md pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
.preview-md pre code{background:none;padding:0;color:#e2e8f0;font-size:11.5px;line-height:1.55;}
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
.preview-md pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
.preview-md pre code{background:none;padding:0;color:var(--pre-text);font-size:11.5px;line-height:1.55;}
.preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;}
.preview-md strong{color:#fff;font-weight:600;}.preview-md em{color:#c9c9e8;}
.preview-md strong{color:var(--strong);font-weight:600;}.preview-md em{color:var(--em);}
.preview-md a{color:var(--blue);text-decoration:underline;}
.preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
.preview-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
@@ -353,14 +456,14 @@
/* Tool cards */
.tool-card{margin-left:0!important;font-size:12px;}
/* Settings modal */
.settings-panel{width:95vw;max-width:95vw;}
.settings-panel{width:95vw;max-width:95vw;min-height:min(580px,88vh);max-height:92vh;}
/* Login page responsive */
.card{width:90vw;max-width:320px;padding:28px 24px;}
}
/* ── Workspace dropdown (topbar) ── */
.ws-chip{user-select:none;}
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.ws-dropdown.open{display:block;}
.ws-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:4px;align-items:flex-start;}
.ws-opt:hover{background:rgba(255,255,255,.07);}
@@ -380,7 +483,7 @@
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
/* ── Profile dropdown + management panel ── */
.profile-chip{user-select:none;color:rgba(168,139,250,.9)!important;}
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
.profile-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:260px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:380px;overflow-y:auto;}
.profile-dropdown.open{display:block;}
.profile-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
.profile-opt:hover{background:rgba(255,255,255,.07);}
@@ -398,7 +501,7 @@
.profile-card-meta{font-size:11px;color:var(--muted);margin-top:3px;padding-left:12px;}
.profile-card-actions{display:flex;gap:4px;flex-shrink:0;}
/* ── Slash command autocomplete dropdown ── */
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;}
.cmd-dropdown.open{display:block;}
.cmd-item{padding:8px 14px;cursor:pointer;transition:background .12s;}
.cmd-item:hover,.cmd-item.selected{background:rgba(255,255,255,.07);}
@@ -418,7 +521,7 @@
.msg-edit-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;}
.msg-edit-send{background:var(--blue);color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;}
.msg-edit-send:hover{opacity:.85;}
.msg-edit-cancel{background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
.msg-edit-cancel{background:var(--hover-bg);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
.msg-edit-cancel:hover{background:rgba(255,255,255,.1);}
/* ── Clear conversation chip ── */
@@ -596,7 +699,7 @@ body.resizing{user-select:none;cursor:col-resize;}
.skill-linked-section{margin-bottom:8px;}
.skill-linked-section h4{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:4px;}
.skill-linked-file{display:block;font-size:12px;padding:3px 6px;border-radius:4px;cursor:pointer;color:var(--blue);text-decoration:none;}
.skill-linked-file:hover{background:rgba(255,255,255,.06);}
.skill-linked-file:hover{background:var(--hover-bg);}
.tool-card-row{margin:0;padding:1px 0;}
.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
.tool-card:hover{border-color:rgba(255,255,255,.12);}
@@ -624,7 +727,7 @@ body.resizing{user-select:none;cursor:col-resize;}
/* ── Settings overlay ── */
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;}
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:380px;max-width:90vw;max-height:80vh;overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:420px;max-width:92vw;max-height:92vh;min-height:min(680px,90vh);overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
.settings-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid var(--border);}
.settings-body{padding:20px;overflow-y:auto;flex:1;}
.settings-field{margin-bottom:16px;}
@@ -666,13 +769,13 @@ body.resizing{user-select:none;cursor:col-resize;}
/* ── Session projects ── */
.project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;}
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:rgba(255,255,255,.04);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
.project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;}
.project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);}
.project-chip.active{background:rgba(124,185,255,.12);color:var(--blue);border-color:rgba(124,185,255,.4);}
.project-chip .color-dot{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0;}
.project-create-btn{font-size:10px;padding:3px 6px;border-radius:12px;cursor:pointer;border:1px dashed var(--border2);background:none;color:var(--muted);opacity:.6;transition:all .15s;}
.project-create-btn:hover{opacity:1;border-color:var(--blue);color:var(--blue);}
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:rgba(20,32,60,.9);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
.project-create-input{font-size:10px;padding:3px 8px;border-radius:12px;border:1px solid rgba(124,185,255,.6);background:var(--surface);color:var(--text);outline:none;width:100px;font-family:inherit;box-shadow:0 0 0 2px rgba(124,185,255,.15);}
.project-picker{position:absolute;right:0;top:100%;background:var(--sidebar);border:1px solid var(--border2);border-radius:8px;padding:4px;z-index:30;min-width:160px;max-width:220px;width:max-content;box-shadow:0 4px 16px rgba(0,0,0,.3);}
.project-picker-item{padding:5px 10px;font-size:11px;border-radius:6px;cursor:pointer;color:var(--muted);transition:all .1s;display:flex;align-items:center;gap:6px;}
.project-picker-item:hover{background:rgba(255,255,255,.08);color:var(--text);}
@@ -682,7 +785,7 @@ body.resizing{user-select:none;cursor:col-resize;}
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;margin-left:4px;vertical-align:middle;}
/* ── Code copy button ── */
.code-copy-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
.code-copy-btn{background:var(--hover-bg);border:1px solid var(--border2);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
/* ── Tool card expand/collapse toggle ── */

View File

@@ -334,6 +334,47 @@ async function refreshSession() {
showToast('Conversation refreshed');
} catch(e) { setStatus('Refresh failed: ' + e.message); }
}
// ── Update banner ──
function _showUpdateBanner(data){
const parts=[];
if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`);
if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`);
if(!parts.length)return;
const msg=$('updateMsg');
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
const banner=$('updateBanner');
if(banner) banner.classList.add('visible');
window._updateData=data;
}
function dismissUpdate(){
const b=$('updateBanner');if(b)b.classList.remove('visible');
sessionStorage.setItem('hermes-update-dismissed','1');
}
async function applyUpdates(){
const btn=$('btnApplyUpdate');
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
const targets=[];
if(window._updateData?.webui?.behind>0) targets.push('webui');
if(window._updateData?.agent?.behind>0) targets.push('agent');
try{
for(const target of targets){
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})});
if(!res.ok){
showToast('Update failed ('+target+'): '+(res.message||'unknown error'));
if(btn){btn.disabled=false;btn.textContent='Update Now';}
return;
}
}
showToast('Updated! Reloading\u2026');
sessionStorage.removeItem('hermes-update-checked');
sessionStorage.removeItem('hermes-update-dismissed');
setTimeout(()=>location.reload(),1500);
}catch(e){
showToast('Update failed: '+e.message);
if(btn){btn.disabled=false;btn.textContent='Update Now';}
}
}
async function checkInflightOnBoot(sid) {
const raw = localStorage.getItem(INFLIGHT_KEY);
if (!raw) return;

View File

@@ -0,0 +1,100 @@
"""
Tests for resolve_model_provider() model routing logic.
Verifies that model IDs are correctly resolved to (model, provider, base_url)
tuples for different provider configurations.
"""
import api.config as config
def _resolve_with_config(model_id, provider=None, base_url=None, default=None):
"""Helper: temporarily set config.cfg model section, call resolve, restore."""
old_cfg = dict(config.cfg)
model_cfg = {}
if provider:
model_cfg['provider'] = provider
if base_url:
model_cfg['base_url'] = base_url
if default:
model_cfg['default'] = default
config.cfg['model'] = model_cfg if model_cfg else {}
try:
return config.resolve_model_provider(model_id)
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
# ── OpenRouter prefix handling ────────────────────────────────────────────
def test_openrouter_free_keeps_full_path():
"""openrouter/free must NOT be stripped to 'free' when provider is openrouter."""
model, provider, base_url = _resolve_with_config(
'openrouter/free', provider='openrouter',
base_url='https://openrouter.ai/api/v1',
)
assert model == 'openrouter/free', f"Expected 'openrouter/free', got '{model}'"
assert provider == 'openrouter'
def test_openrouter_model_with_provider_prefix():
"""anthropic/claude-sonnet-4.6 via openrouter keeps full path."""
model, provider, base_url = _resolve_with_config(
'anthropic/claude-sonnet-4.6', provider='openrouter',
base_url='https://openrouter.ai/api/v1',
)
assert model == 'anthropic/claude-sonnet-4.6'
assert provider == 'openrouter'
# ── Direct provider prefix stripping ─────────────────────────────────────
def test_anthropic_prefix_stripped_for_direct_api():
"""anthropic/claude-sonnet-4.6 strips prefix when provider is anthropic."""
model, provider, base_url = _resolve_with_config(
'anthropic/claude-sonnet-4.6', provider='anthropic',
)
assert model == 'claude-sonnet-4.6'
assert provider == 'anthropic'
def test_openai_prefix_stripped_for_direct_api():
"""openai/gpt-5.4-mini strips prefix when provider is openai."""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4-mini', provider='openai',
)
assert model == 'gpt-5.4-mini'
assert provider == 'openai'
# ── Cross-provider routing ───────────────────────────────────────────────
def test_cross_provider_routes_through_openrouter():
"""Picking openai model when config is anthropic routes via openrouter."""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4-mini', provider='anthropic',
)
assert model == 'openai/gpt-5.4-mini'
assert provider == 'openrouter'
assert base_url is None # openrouter uses its own endpoint
# ── Bare model names ─────────────────────────────────────────────────────
def test_bare_model_uses_config_provider():
"""A model name without / uses the config provider and base_url."""
model, provider, base_url = _resolve_with_config(
'gemma-4-26B', provider='custom',
base_url='http://192.168.1.160:4000',
)
assert model == 'gemma-4-26B'
assert provider == 'custom'
assert base_url == 'http://192.168.1.160:4000'
def test_empty_model_returns_config_defaults():
"""Empty model string returns config provider and base_url."""
model, provider, base_url = _resolve_with_config(
'', provider='anthropic',
)
assert model == ''
assert provider == 'anthropic'

119
tests/test_sprint26.py Normal file
View File

@@ -0,0 +1,119 @@
"""
Sprint 26 Tests: pluggable UI themes — settings persistence, theme default,
custom theme names accepted.
"""
import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
# ── Theme settings ───────────────────────────────────────────────────────
def test_settings_default_theme():
"""Default theme should be 'dark'."""
d, status = get("/api/settings")
assert status == 200
assert d.get("theme") == "dark"
def test_settings_set_theme_light():
"""Setting theme to 'light' should persist and round-trip."""
try:
d, status = post("/api/settings", {"theme": "light"})
assert status == 200
d2, _ = get("/api/settings")
assert d2.get("theme") == "light"
finally:
# Reset to dark
post("/api/settings", {"theme": "dark"})
def test_settings_set_theme_solarized():
"""Setting theme to 'solarized' should persist."""
try:
post("/api/settings", {"theme": "solarized"})
d, _ = get("/api/settings")
assert d.get("theme") == "solarized"
finally:
post("/api/settings", {"theme": "dark"})
def test_settings_set_theme_monokai():
"""Setting theme to 'monokai' should persist."""
try:
post("/api/settings", {"theme": "monokai"})
d, _ = get("/api/settings")
assert d.get("theme") == "monokai"
finally:
post("/api/settings", {"theme": "dark"})
def test_settings_set_theme_nord():
"""Setting theme to 'nord' should persist."""
try:
post("/api/settings", {"theme": "nord"})
d, _ = get("/api/settings")
assert d.get("theme") == "nord"
finally:
post("/api/settings", {"theme": "dark"})
def test_settings_set_theme_slate():
"""Setting theme to 'slate' should persist."""
try:
post("/api/settings", {"theme": "slate"})
d, _ = get("/api/settings")
assert d.get("theme") == "slate"
finally:
post("/api/settings", {"theme": "dark"})
def test_settings_custom_theme_accepted():
"""Custom theme names should be accepted (no enum gate)."""
try:
d, status = post("/api/settings", {"theme": "my-custom-theme"})
assert status == 200
d2, _ = get("/api/settings")
assert d2.get("theme") == "my-custom-theme"
finally:
post("/api/settings", {"theme": "dark"})
def test_theme_does_not_break_other_settings():
"""Setting theme should not disturb other settings."""
d_before, _ = get("/api/settings")
send_key_before = d_before.get("send_key")
try:
post("/api/settings", {"theme": "nord"})
d_after, _ = get("/api/settings")
assert d_after.get("send_key") == send_key_before
assert d_after.get("theme") == "nord"
finally:
post("/api/settings", {"theme": "dark"})
def test_theme_survives_round_trip():
"""Theme set via POST should appear in subsequent GET."""
try:
post("/api/settings", {"theme": "monokai"})
d, status = get("/api/settings")
assert status == 200
assert d["theme"] == "monokai"
finally:
post("/api/settings", {"theme": "dark"})