Compare commits

...

14 Commits

Author SHA1 Message Date
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
nesquena-hermes
6d4c258d90 docs: v0.33 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 20:09:59 -07:00
nesquena-hermes
c312dd36ca fix: state_sync.py -- correct class name, constructor type, title API, connection leak
Three bugs found during review:
1. Class is SessionDB not HermesState -- would silently no-op on every install
2. SessionDB.__init__ takes Path not str -- would crash with AttributeError
3. _execute_write() takes a callable not SQL+params -- wrong signature.
   Replaced with public set_session_title() API.
4. Each call opened a persistent SQLite connection and never closed it.
   Added try/finally db.close() to prevent WAL leak under sustained load.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 20:08:20 -07:00
Nathan Esquenazi
bb595afde9 feat: opt-in state.db sync for /insights visibility (#92)
WebUI sessions were invisible to 'hermes /insights' because the WebUI
bypasses the gateway and calls AIAgent.run_conversation() directly,
never writing to state.db.

New 'Sync usage to /insights' setting (default: off) that mirrors
WebUI session metadata (tokens, cost, model, title) into state.db
after each turn. Uses absolute token counts to avoid double-counting.

Components:
- api/state_sync.py: bridge module with sync_session_start() and
  sync_session_usage(). Uses ensure_session() (idempotent) and
  update_token_counts(absolute=True). All wrapped in try/except.
- api/config.py: new 'sync_to_insights' boolean setting
- api/streaming.py: calls sync_session_usage() after s.save()
- api/routes.py: same for the non-streaming chat path
- Settings UI: checkbox toggle with description

Default off because:
- Writing to state.db while CLI/gateway also writes could cause
  WAL lock contention on busy systems
- Some users may not want WebUI sessions in /insights stats

Closes #92

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:07:05 -07:00
Nathan Esquenazi
e0d52f39dc Merge pull request #91 from nesquena/feat/auto-compaction-handling
Some checks failed
Release & Docker / release (push) Has been cancelled
feat: handle auto-compaction side effects + /compact command (#90)
2026-04-04 19:00:15 -07:00
Nathan Esquenazi
4a6769ec08 docs: v0.32 release notes, version bump for auto-compaction handling
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:00:02 -07:00
Nathan Esquenazi
2797e5189b feat: context window usage indicator with real agent data
The context indicator in the composer footer now shows real data from
the agent's context compressor instead of hardcoded estimates:

- last_prompt_tokens / context_length (e.g. '12.4k / 200k (6%)')
- Bar color: blue <50%, yellow 50-75%, red >75%
- Hover tooltip shows exact numbers + compression threshold
- Cost appended when available

Backend: streaming.py now reads context_length, threshold_tokens, and
last_prompt_tokens from agent.context_compressor after run_conversation()
and includes them in the usage dict sent with the 'done' SSE event.

This matches the CLI's context window display (the bar that shows
current context vs total window).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:50:17 -07:00
Nathan Esquenazi
429a0ea228 feat: handle auto-compaction side effects + /compact command
The agent's run_conversation() already triggers context compression
internally, but the WebUI was unaware of the side effects:

1. Session ID rotation: compression creates a new session_id inside
   the agent. The WebUI kept writing to the old session file, causing
   silent data loss. Fix: detect agent.session_id mismatch after
   run_conversation(), rename the session file, and update in-memory
   caches.

2. No user notification: compression was invisible. Fix: emit a
   'compressed' SSE event when compression is detected. Frontend shows
   a system message and toast.

3. No manual control: Fix: add /compact slash command that sends a
   message to the agent requesting context compression. Shows in the
   autocomplete dropdown.

Detection works two ways:
- agent.session_id != original session_id (ID rotation)
- agent.context_compressor.compression_count > 0 (compressor state)

Closes #90

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:46:34 -07:00
17 changed files with 955 additions and 51 deletions

View File

@@ -5,6 +5,81 @@
---
## [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*
### Features
- **Opt-in `/insights` sync.** New "Sync usage to /insights" setting (default: off). When enabled, after each turn the WebUI mirrors session token usage, cost, model, and title into `state.db` so `hermes /insights` includes browser session activity. (#92, #93)
### Bug Fixes
- **state_sync.py correctness fixes.** Three bugs in the initial implementation caught during code review: wrong class name (`HermesState``SessionDB`), wrong constructor argument type (`str``Path`), wrong title update method (`_execute_write` with bad signature → `set_session_title`). Also fixed a SQLite connection leak (persistent connection opened per call, never closed). (#95)
---
## [v0.32] Auto-Compaction Handling + /compact Command (Issue #90)
*April 5, 2026 | 424 tests*
### Features
- **Auto-compaction detection.** When the agent's `run_conversation()` triggers
context compression and rotates the session ID, the WebUI detects the mismatch
and renames the session file + cache entry so messages don't split across files.
- **`compressed` SSE event.** Frontend receives a notification when compression
fires, shows a system message ("Context was auto-compressed") and a toast.
- **`/compact` slash command.** Type `/compact` to request the agent compress
the conversation context. Sends a natural-language message that triggers the
agent's compression preflight.
- **Real context window data.** The context usage indicator now uses actual
`context_length`, `threshold_tokens`, and `last_prompt_tokens` from the agent's
compressor instead of the client-side model name lookup. Tooltip shows the
auto-compress threshold. Hides gracefully when the agent has no compressor.
### Architecture
- `api/streaming.py`: Session ID mismatch detection after `run_conversation()`,
file rename, SESSIONS cache update under lock, `compressed` SSE event,
`context_length`/`threshold_tokens`/`last_prompt_tokens` in usage dict.
- `static/commands.js`: `/compact` command.
- `static/messages.js`: `compressed` SSE event handler.
- `static/ui.js`: `_syncCtxIndicator()` rewritten to use server-side compressor
data instead of client-side model estimates.
---
## [v0.31.2] CLI session delete fix
*April 5, 2026 | 424 tests*
@@ -1113,4 +1188,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: v0.31, April 4, 2026 | Tests: 424*
*Last updated: v0.34, April 5, 2026 | Tests: 433*

View File

@@ -314,17 +314,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 +400,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,7 +3,7 @@
> 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.31.2 (April 5, 2026)
> Last updated: v0.33 (April 5, 2026)
> Tests: 424 total (424 passing, 0 failures)
> Source: <repo>/
@@ -40,6 +40,8 @@
| 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 |
---
@@ -206,7 +208,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)
- [ ] Pluggable UI themes -- light, dark, Solarized, Monokai, Nord (Sprint 26, PLANNED)
- [ ] 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.31 | 424 tests | Daily driver ready
> Current state: v0.34 | 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
@@ -75,7 +75,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 +118,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 +511,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 +610,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 +893,269 @@ genuinely differentiating for an open-source project
---
## Sprint 26 -- Pluggable UI Themes (PLANNED)
**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.31.2 | 424 tests*
*Current version: v0.34 | 433 tests*
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
*Horizon sprint: Sprint 26 (Pluggable UI Themes)*

117
THEMES.md Normal file
View File

@@ -0,0 +1,117 @@
# Hermes Web UI — Themes
Hermes Web UI supports pluggable color themes. Five 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. |
---
## 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"] {
--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 */
}
```
That's it. Override any or all of these variables. The entire UI adapts
automatically because every color reference uses `var(--name)`.
### 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 additional scrollbar overrides to avoid dark scrollbars
on a light background. See the built-in light theme for the pattern.
- 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

@@ -652,6 +652,8 @@ _SETTINGS_DEFAULTS = {
'send_key': 'enter', # 'enter' or 'ctrl+enter'
'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
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
'password_hash': None, # SHA-256 hash; None = auth disabled
}
@@ -671,7 +673,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'}
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'}
def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""

View File

@@ -995,6 +995,20 @@ def _handle_chat_sync(handler, body):
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
s.messages = result.get('messages') or s.messages
s.title = title_from(s.messages, s.title); s.save()
# Sync to state.db for /insights (opt-in setting)
try:
if load_settings().get('sync_to_insights'):
from api.state_sync import sync_session_usage
sync_session_usage(
session_id=s.session_id,
input_tokens=s.input_tokens or 0,
output_tokens=s.output_tokens or 0,
estimated_cost=s.estimated_cost,
model=s.model,
title=s.title,
)
except Exception:
pass
return j(handler, {
'answer': result.get('final_response') or '',
'status': 'done' if result.get('completed', True) else 'partial',

101
api/state_sync.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Hermes Web UI -- Optional state.db sync bridge.
Mirrors WebUI session metadata (token usage, title, model) into the
hermes-agent state.db so that /insights, session lists, and cost
tracking include WebUI activity.
This is opt-in via the 'sync_to_insights' setting (default: off).
All operations are wrapped in try/except -- if state.db is unavailable,
locked, or the schema doesn't match, the WebUI continues normally.
The bridge uses absolute token counts (not deltas) because the WebUI
Session object already accumulates totals across turns. This avoids
any double-counting risk.
"""
import os
from pathlib import Path
def _get_state_db():
"""Get a SessionDB instance for the active profile's state.db.
Returns None if hermes_state is not importable or DB is unavailable.
Each caller is responsible for calling db.close() when done.
"""
try:
from hermes_state import SessionDB
except ImportError:
return None
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
db_path = hermes_home / 'state.db'
if not db_path.exists():
return None
try:
return SessionDB(db_path)
except Exception:
return None
def sync_session_start(session_id, model=None):
"""Register a WebUI session in state.db (idempotent).
Called when a session's first message is sent.
"""
db = _get_state_db()
if not db:
return
try:
db.ensure_session(
session_id=session_id,
source='webui',
model=model,
)
except Exception:
pass # never crash the WebUI for sync failures
finally:
try:
db.close()
except Exception:
pass
def sync_session_usage(session_id, input_tokens=0, output_tokens=0,
estimated_cost=None, model=None, title=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).
"""
db = _get_state_db()
if not db:
return
try:
# Ensure session exists first (idempotent)
db.ensure_session(session_id=session_id, source='webui', model=model)
# Set absolute token counts
db.update_token_counts(
session_id=session_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
estimated_cost_usd=estimated_cost,
model=model,
absolute=True,
)
# Update title if we have one, using the public API
if title:
try:
db.set_session_title(session_id, title)
except Exception:
pass
except Exception:
pass # never crash the WebUI for sync failures
finally:
try:
db.close()
except Exception:
pass

View File

@@ -12,6 +12,7 @@ from pathlib import Path
from api.config import (
STREAMS, STREAMS_LOCK, CANCEL_FLAGS, CLI_TOOLSETS,
LOCK, SESSIONS, SESSION_DIR,
_get_session_agent_lock, _set_thread_env, _clear_thread_env,
resolve_model_provider,
)
@@ -206,6 +207,40 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
persist_user_message=msg_text,
)
s.messages = result.get('messages') or s.messages
# ── Handle context compression side effects ──
# If compression fired inside run_conversation, the agent may have
# rotated its session_id. Detect and fix the mismatch so the WebUI
# continues writing to the correct session file.
_agent_sid = getattr(agent, 'session_id', None)
_compressed = False
if _agent_sid and _agent_sid != session_id:
old_sid = session_id
new_sid = _agent_sid
# Rename the session file
old_path = SESSION_DIR / f'{old_sid}.json'
new_path = SESSION_DIR / f'{new_sid}.json'
s.session_id = new_sid
with LOCK:
if old_sid in SESSIONS:
SESSIONS[new_sid] = SESSIONS.pop(old_sid)
if old_path.exists() and not new_path.exists():
try:
old_path.rename(new_path)
except OSError:
pass
_compressed = True
# Also detect compression via the result dict or compressor state
if not _compressed:
_compressor = getattr(agent, 'context_compressor', None)
if _compressor and getattr(_compressor, 'compression_count', 0) > 0:
_compressed = True
# Notify the frontend that compression happened
if _compressed:
put('compressed', {
'message': 'Context auto-compressed to continue the conversation',
})
# Stamp 'timestamp' on any messages that don't have one yet
_now = time.time()
for _m in s.messages:
@@ -274,7 +309,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
m['attachments'] = attachments
break
s.save()
# Sync to state.db for /insights (opt-in setting)
try:
from api.config import load_settings as _load_settings
if _load_settings().get('sync_to_insights'):
from api.state_sync import sync_session_usage
sync_session_usage(
session_id=s.session_id,
input_tokens=s.input_tokens or 0,
output_tokens=s.output_tokens or 0,
estimated_cost=s.estimated_cost,
model=model,
title=s.title,
)
except Exception:
pass # never crash the stream for sync failures
usage = {'input_tokens': input_tokens, 'output_tokens': output_tokens, 'estimated_cost': estimated_cost}
# Include context window data from the agent's compressor for the UI indicator
_cc = getattr(agent, 'context_compressor', None)
if _cc:
usage['context_length'] = getattr(_cc, 'context_length', 0) or 0
usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0
usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
put('done', {'session': s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}, 'usage': usage})
finally:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)

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,7 @@ 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;}
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;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;}
// 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

@@ -5,10 +5,12 @@
const COMMANDS=[
{name:'help', desc:'List available commands', fn:cmdHelp},
{name:'clear', desc:'Clear conversation messages', fn:cmdClear},
{name:'compact', desc:'Compress conversation context', fn:cmdCompact},
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'},
{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){
@@ -99,6 +101,15 @@ async function cmdNew(){
showToast('New session created');
}
function cmdCompact(){
// Send as a regular message to the agent -- the agent's run_conversation
// preflight will detect the high token count and trigger _compress_context.
// We send a user message so it appears in the conversation.
$('msg').value='Please compress and summarize the conversation context to free up space.';
send();
showToast('Requesting context compression...');
}
async function cmdUsage(){
const next=!window._showTokenUsage;
window._showTokenUsage=next;
@@ -112,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.31.2</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.34</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>
@@ -311,7 +312,7 @@
<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">
@@ -329,6 +330,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)">
@@ -343,6 +355,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
Sync usage to /insights
</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" 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

@@ -177,6 +177,17 @@ async function send(){
renderSessionList();setBusy(false);setStatus('');
});
source.addEventListener('compressed',e=>{
// Context was auto-compressed during this turn -- show a system message
if(!S.session||S.session.session_id!==activeSid) return;
try{
const d=JSON.parse(e.data);
const sysMsg={role:'assistant',content:'*[Context was auto-compressed to continue the conversation]*'};
S.messages.push(sysMsg);
showToast(d.message||'Context compressed');
}catch(err){}
});
source.addEventListener('apperror',e=>{
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.

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,6 +993,7 @@ async function loadSettingsPanel(){
}
}catch(e){}
modelSel.value=settings.default_model||'';
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
}
// Populate workspace dropdown from /api/workspaces
const wsSel=$('settingsWorkspace');
@@ -954,17 +1008,23 @@ async function loadSettingsPanel(){
}
}catch(e){}
wsSel.value=settings.default_workspace||'';
wsSel.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;syncCb.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');
@@ -979,19 +1039,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;
// Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){
try{
@@ -999,7 +1062,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;}
}
@@ -1008,10 +1073,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);
}
@@ -1041,10 +1108,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

@@ -4,6 +4,34 @@
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
}
/* ── Slate theme (warm charcoal, lighter than dark, easier on the eyes) ── */
: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;
}
/* ── Light theme (warm off-white, softer than pure white) ── */
: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:#e8e4de;
}
: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);}
/* ── 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;
}
/* ── 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;
}
/* ── 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;
}
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;}
@@ -353,7 +381,7 @@
/* 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;}
}
@@ -624,7 +652,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;}

View File

@@ -88,18 +88,11 @@ function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'
function _syncCtxIndicator(usage){
const el=$('ctxIndicator');
if(!el)return;
const inTok=usage.input_tokens||0;
const outTok=usage.output_tokens||0;
const total=inTok+outTok;
if(!total){el.style.display='none';return;}
const promptTok=usage.last_prompt_tokens||usage.input_tokens||0;
const ctxWindow=usage.context_length||0;
if(!promptTok||!ctxWindow){el.style.display='none';return;}
el.style.display='';
// Estimate context window from model name (rough, covers major families)
// TODO: fetch exact values from server or model metadata API
const _CTX={claude:200000,gemini:1000000,'gpt-4o':128000,'gpt-5':128000,o3:200000,o4:200000,deepseek:128000,llama:128000};
const _m=(S.session&&S.session.model||'').toLowerCase();
let ctxWindow=128000;
for(const[k,v]of Object.entries(_CTX)){if(_m.includes(k)){ctxWindow=v;break;}}
const pct=Math.min(100,Math.round((inTok/ctxWindow)*100));
const pct=Math.min(100,Math.round((promptTok/ctxWindow)*100));
const bar=$('ctxBar');
const label=$('ctxLabel');
if(bar){
@@ -108,10 +101,15 @@ function _syncCtxIndicator(usage){
}
if(label){
const cost=usage.estimated_cost;
let text=`${_fmtTokens(inTok)} in \u00b7 ${_fmtTokens(outTok)} out`;
let text=`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)}`;
if(pct>0) text+=` (${pct}%)`;
if(cost) text+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
label.textContent=text;
}
// Update title with detailed info
const threshold=usage.threshold_tokens||0;
el.title=`Context: ${_fmtTokens(promptTok)} of ${_fmtTokens(ctxWindow)} tokens used`
+(threshold?`\nAuto-compress at ${_fmtTokens(threshold)} (${Math.round(threshold/ctxWindow*100)}%)`:'');
}
function scrollIfPinned(){

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"})