Commit graph

17236 commits

Author SHA1 Message Date
Teknium
2e9765b34e fix(gateway): route hygiene-timeout warning via profile-aware adapter lookup + verify lock reacquire after fence cancel
- gateway/run.py: use _adapter_for_source(source) instead of the raw
  adapters.get(source.platform) map so the compression-timeout warning
  respects transport provenance, relay ingress, and multiplexed profiles
  (matches every other user-facing send in the hygiene block).
- tests: add a lock-release verification regression — a fence-cancelled
  hygiene compression must leave the per-session compression lock free so
  the next attempt (manual /compress retry) acquires it and commits
  normally.
2026-07-23 07:26:27 -07:00
kshitij
ca9c30c7f0 fix(gateway): bound hygiene compression failures 2026-07-23 07:26:27 -07:00
Teknium
49a8c61cac fix(context-engine): route pre-API and idle compaction status through the quiet-engine resolver
Follow-up for the salvaged #35191: the mid-turn pre-API pressure emit in
conversation_loop.py and the idle-resume emit in turn_context.py were not
routed through automatic_compaction_status_message, so an engine with
emit_automatic_compaction_status=False still leaked those lines. Both now
resolve through the hook (phases "pre_api" and "idle") while keeping the
#69550 template constants as the default wording. Suppression also skips the
#69546 structured 'compacted' terminal edge for compress-phase events that
opened no visible phase; failure warnings (_emit_warning) remain never
suppressible, pinned by test.
2026-07-23 07:26:15 -07:00
Stephen Schoettler
d81a3dbfbb fix(context-engine): adapt quiet compaction status to turn-context refactor 2026-07-23 07:26:15 -07:00
Stephen Schoettler
28dced2440 test: isolate quiet compaction status assertions 2026-07-23 07:26:15 -07:00
Stephen Schoettler
4035d70bbe fix(context-engine): honor quiet compaction status 2026-07-23 07:26:15 -07:00
Teknium
2ca38e5df4 test(compression): pin scaffolding-tail standalone append + stale-snapshot refresh
Covers the follow-up hardening: continuation-marker and summary-as-user
tails keep the flagged standalone snapshot (zero-user provenance #69292
verified via _transcript_has_real_user_turn on the projected rows),
stale snapshot rows are refreshed in place, a previously merged snapshot
is stripped before re-injection, and an all-completed todo store injects
nothing (#26981).
2026-07-23 07:25:44 -07:00
Teknium
06a2d77372 fix(compression): gate todo-snapshot merge on real-user tails, refresh stale snapshots
Follow-up hardening on the salvaged merge-into-trailing-turn fix:

- Merge only into REAL user tails (_is_real_user_message probe). Merging
  into scaffolding tails (continuation marker, summary-as-user handoff)
  would upgrade them to real-user evidence after SessionDB projection
  strips the flags, breaking zero-user provenance (#69292 -
  _is_synthetic_compression_user_turn keys on the TODO_INJECTION_HEADER
  content marker, which merge-at-tail would bury mid-content).
- Strip a previously merged snapshot block before re-injection so
  repeated boundaries refresh rather than accumulate todo state, and
  refresh a bare stale snapshot row in place instead of stacking a
  duplicate (empty/stale-skip semantics from #26981 by @YLChen-007).
- Scaffolding tails keep the flagged standalone append (pre-#53890
  status quo; adjacent user rows are repaired downstream by
  repair_message_sequence / _merge_consecutive_roles).
2026-07-23 07:25:44 -07:00
Yingliang Zhang
33fd705421 fix(compression): preserve multimodal todo tails 2026-07-23 07:25:44 -07:00
Yingliang Zhang
d2bb6cc251 fix(compression): merge todo snapshot into trailing user msg to avoid consecutive user/user turns
After context compression, the preserved todo list was unconditionally
appended as a standalone user message. When the compressed transcript
already ends with a user message (common case), this creates consecutive
user/user turns — a role-alternation violation some providers reject.

Fix: fold the snapshot into the trailing user message (blank-line separated)
when one exists with plain-string content. Falls back to append when the
tail is non-user, empty, or has structured (list) content.

Rebased on current upstream/main.

Closes #53890
2026-07-23 07:25:44 -07:00
Teknium
18d83b4da9 test(agent): pin the #61932 all-oversized-tail dead-end shape as compressible
Regression test for the exact issue #61932 report: head + an 8-message
protected tail made exclusively of oversized tool pairs.  Pre-fix,
compress_start >= compress_end made compress() a pure no-op and the
retry loop ended in 'Cannot compress further'; post-fix the Phase-1
pressure demotion reclaims the tail in one pass while preserving
tool_call/tool_result pairing.
2026-07-23 07:25:33 -07:00
giggling-ginger
d12ea20009 test(agent): cover protected-tail last resort 2026-07-23 07:25:33 -07:00
giggling-ginger
fe2ae40983 fix(agent): demote oversized tool results in protected compression tail
After multiple in-place compactions, short tool-heavy sessions can leave
nearly every remaining message inside protect_last_n while those messages
are huge completed file/tool outputs. The middle compress window then
makes no material token progress and the turn dies with
"Cannot compress further" (#61932).

Cap the prune message floor at the same bound as tail-cut, and under
pressure demote bulky protected-tail tool bodies (keeping a short recent
floor) so preflight can reclaim headroom without wiping the active ask.
2026-07-23 07:25:33 -07:00
Teknium
4fbfb26704 test(compression): audit abort paths for per-attempt in-place state reset
Follow-up for salvaged #58629: thread the previous flush baseline through
the idle-compaction caller in turn_context.py (the one caller the original
PR predates), and add regression coverage that every early-return path in
compress_context (breaker skip, no-progress, plus the completed-rotation
boundary) resets the per-attempt in-place outcome so a stale
_last_compaction_in_place from an earlier successful in-place compaction
can never baseline unflushed turns as persisted.
2026-07-23 07:25:21 -07:00
Brett Bonner
79a83830ba test(compression): verify abort persistence after restart 2026-07-23 07:25:21 -07:00
Brett Bonner
03c96b7ab5 test(compression): retain durable persistence regression 2026-07-23 07:25:21 -07:00
Brett Bonner
17b3a4bd41 fix(compression): preserve flush baseline after abort 2026-07-23 07:25:21 -07:00
Teknium
929c952596 fix(agent): wire should_compress_preflight into the turn-start preflight flow
Relocates the #20424 wiring: the preflight region moved out of
run_agent.py into agent/turn_context.py (and through the
compression.max_attempts unification, #69315), so the contributor's
elif branch is reapplied at its current home as the else arm of the
threshold dispatch chain.

Integration contracts:
- Byte-identical default: the built-in ContextCompressor inherits
  ContextEngine.should_compress_preflight() -> False, so the default
  path performs no compression and touches no turn bookkeeping
  (pinned by test_builtin_compressor_default_sub_threshold_path_unchanged).
- Attempt-cap: the engine gets exactly ONE compress() pass per turn,
  mutually exclusive with the cap-bounded threshold multi-pass loop,
  so turn-start passes stay within the resolved
  compression.max_attempts budget in every case.
- No-op blocking (#64382 / 377244f7c): an engine pass that no-ops
  (_compress_context returns the input list object) neither sets nor
  clears preflight_compression_blocked and does not re-baseline the
  flush history — a sub-threshold maintenance no-op proves nothing
  about over-threshold compressibility.
- Engine exceptions are swallowed at debug level; cooldown/defer/
  codex-native gates run before the hook is ever consulted.

Salvaged from #20424 by @Beandon13. Fixes #20316.
2026-07-23 07:25:08 -07:00
Brandon Zarnitz
d1c0c33a8b fix(run_agent): call should_compress_preflight() for sub-threshold engines (#20316)
Context engines that override ``should_compress_preflight()`` (e.g. the
hermes-lcm plugin's incremental leaf-chunk compaction) never had their
hook fired by ``run_conversation`` because the preflight block exited
early once the hardcoded ``>= threshold_tokens`` check failed.  As a
result, ``LCM_DEFERRED_MAINTENANCE_ENABLED=1`` and friends were inert
and accumulated raw_backlog debt indefinitely.

Add an ``elif`` branch that delegates to the engine's preflight hook
when the legacy threshold check does not fire.  The default
``ContextEngine.should_compress_preflight()`` returns ``False`` so the
built-in ``ContextCompressor`` is unaffected; engines opting in get a
chance to ingest messages and request a single ``compress()`` pass for
deferred maintenance.  Exceptions are swallowed at debug level so a
buggy engine cannot break an otherwise-healthy turn.

Closes #20316
2026-07-23 07:25:08 -07:00
Teknium
d462116226 test(cli): prove /compress type-ahead queue-drain; map contributor email
- tests/cli/test_compress_type_ahead.py: end-to-end proof of the PR #68284
  docstring claim — a prompt queued into _pending_input while /compress runs
  survives compaction untouched and is the next item process_loop drains,
  i.e. it is processed against the compacted history. Plus a structural
  guard that handle_enter never gates on _command_running /
  _command_blocks_input (read-only enforcement belongs solely to the
  TextArea Condition; a busy-gate in handle_enter would silently drop
  type-ahead input).
- tests/test_cli_manual_compress.py: update the one remaining _busy_command
  stub (added on main after the PR branched) to accept the new
  blocks_input kwarg.
- contributors/emails: map lucas@policastromd.com -> enzo2.
2026-07-23 07:24:46 -07:00
Lucas Policastro
0bc7fb2b3b fix(cli): keep composer editable during compression 2026-07-23 07:24:46 -07:00
Teknium
2cabeeabca refactor(tui): relocate /compress arg parsing into _compress_session_history choke point
Follow-up to the salvaged #35533 fix: instead of parsing 'here [N]' only in
_mirror_slash_side_effects, parse it inside _compress_session_history — the
single helper all three manual-compress routes converge on (session.compress
RPC, command.dispatch /compress|/compact, slash-exec mirror). Every route now
honors the boundary-aware forms (here [N], up to here, --keep N) with the
same head/tail split + rejoin as cli.py and gateway/slash_commands.py, and
keeps the choke point's existing guards (lock-free LLM call, history_version
race check, deferred context-engine notification).

Tests: choke-point unit tests for 'here N', degenerate-split fallback, and
focus-topic passthrough, plus endpoint-level tests on each of the three
routes.
2026-07-23 07:24:34 -07:00
AhmetArif0
9284a3402f fix(tui-gateway): parse partial compress args in /compress here [N]
_mirror_slash_side_effects() passed the raw argument after /compress
directly as focus_topic to _compress_session_history. So /compress here
3 silently used "here 3" as a summary focus topic and did a full
compress instead of preserving the last 3 exchanges verbatim.

Fix: call parse_partial_compress_args() on the argument first. When it
detects a boundary-aware form (here, here N, up to here, --keep N),
split the history into head/tail using split_history_for_partial_compress,
compress only the head via agent._compress_context, and rejoin with
rejoin_compressed_head_and_tail — exactly mirroring cli.py and
gateway/run.py's /compress here implementation (PR #35252).

Non-boundary forms (plain /compress, /compress <focus>) fall through
to _compress_session_history unchanged.
2026-07-23 07:24:34 -07:00
mattshapsss
9d4cc12605 fix(photon): register Photon as a low-verbosity display tier
Photon (managed iMessage) shipped without a _PLATFORM_DEFAULTS entry, so it
inherited the noisy global ('all') display defaults and narrated tool
progress / heartbeats / busy-ack detail into a permanent-message iMessage
thread. Register it as TIER_LOW alongside BlueBubbles and Signal, plus a
regression test guarding the tier.

Salvaged from #50511 (Photon tier piece only).
2026-07-23 07:24:21 -07:00
wz-heng
91546b8337 fix: preserve named custom provider vision overrides 2026-07-23 17:57:33 +05:30
kshitij
97d51ca20d fix: use canonical _get_platform_tools resolver for memory tool status
The PR's inline toolset resolution (checking 'memory' in cli_toolsets
list) produced wrong results for composite toolsets like 'hermes-cli'
which expand to include the memory tool. Replace with the canonical
_get_platform_tools() from tools_config.py which correctly handles
composite toolsets and all edge cases.

Update tests to mock _get_platform_tools instead of raw config.
2026-07-23 17:50:20 +05:30
kshitij
c82c5786f4 test: cover config-aware memory status labels + AUTHOR_MAP entry
Add tests/hermes_cli/test_memory_status.py with 11 tests covering:
- No hardcoded 'always active' label
- memory_enabled, user_profile_enabled, memory toolset indicators
- Tool enabled/disabled via platform_toolsets.cli
- Provider still shown alongside indicators

Add huajiang@tubi.tv → thirstycrow to AUTHOR_MAP (PR #23630 salvage).
2026-07-23 17:50:20 +05:30
Hua Jiang
4c99a44e6d fix: hermes memory status now reads actual config instead of hardcoded 'always active'
The 'Built-in: always active' label was a hardcoded string that never
reflected the user's actual configuration. It now shows three separate
indicators, each reading from the real source of truth:

  - Memory injection:  reads memory.memory_enabled from config.yaml
  - User profile:      reads memory.user_profile_enabled from config.yaml
  - Memory tool:       checks if 'memory' is in platform_toolsets.cli
                       (or defaults to enabled if no explicit list)

Before:
  Built-in:  always active

After:
  Built-in (MEMORY.md / USER.md):
    Memory injection:   disabled ✗
    User profile:       disabled ✗
    Memory tool:        disabled ✗
2026-07-23 17:50:20 +05:30
kshitijk4poor
2841a9cbca chore: add contributor email mapping for fangliquanflq 2026-07-23 17:38:05 +05:30
Fangliquan
9220c0c0bb fix(agent): close tool-result tails on invalid-tool and truncated-tool early returns
Invalid-tool exhaustion and truncated-tool early returns skipped finalize_turn, leaving role=tool transcripts that become tool→user on the next turn for strict providers. Call close_interrupted_tool_sequence before persist on those paths (same as interrupt aborts).
2026-07-23 17:38:05 +05:30
kshitij
8058e01834 refactor: dedupe check_info call + fix trailing whitespace in doctor
Hoist the duplicated check_info(source_id) call out of both
if/else branches into a single call after the branch. Remove
trailing whitespace on the blank line after the except block.

Follow-up cleanup for PR #69981.
2026-07-23 17:32:38 +05:30
kshitij
7419de6ac6 refactor: simplify WAL-reset gate warning dedup + tuple handling
Consolidate the two near-identical warning strings in
_log_wal_reset_bug_once into a single logger.warning call with an
action variable. Remove overengineered defensive tuple-length handling
in is_sqlite_wal_reset_vulnerable (sqlite3.sqlite_version_info always
returns a 3-tuple). Remove extra blank line.

Follow-up cleanup for PR #69981.
2026-07-23 17:32:38 +05:30
HexLab98
a94f8e69e1 test(state): cover SQLite WAL-reset version gate and doctor probe
Assert the version matrix, fresh-DB DELETE fallback, already-WAL left
alone (no checkpoint/DELETE), fixed-SQLite WAL path, and warn-only
doctor output for vulnerable builds (#69784).
2026-07-23 17:32:38 +05:30
HexLab98
953cbc0300 fix(state): refuse WAL on SQLite builds with the WAL-reset bug
On vulnerable SQLite (e.g. 3.50.4), do not enable WAL for fresh/non-WAL
shared databases — prefer DELETE instead. Leave existing on-disk WAL
alone (no live downgrade under concurrent gateway/cron openers). Surface
Python/SQLite version details as a doctor warning (#69784).
2026-07-23 17:32:38 +05:30
brooklyn!
8fc278207b
Merge pull request #69938 from NousResearch/bb/desktop-dev-fixes
fix(desktop): keep gateway session alive across Vite HMR
2026-07-23 02:22:51 -05:00
hermes-seaeye[bot]
44b171fe3d
fmt(js): npm run fix on merge (#69941)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 07:19:06 +00:00
Brooklyn Nicholson
ecc6aec4bf fix(desktop): isolate gateway HMR survivor across vitest cases
Vitest keeps import.meta.hot truthy, so boot-effect cleanup parks the
open socket; drain it between cases so the next test boots fresh.
2026-07-23 02:15:39 -05:00
Brooklyn Nicholson
cbea2acbb8 fix(desktop): gate HMR session-survival entirely out of prod builds
Guard the globalThis gateway-state container and the survivor park/adopt
calls on the import.meta.hot literal (not the runtime hmrActive() helper),
so Vite dead-code-eliminates every HMR path in production. Prod now uses a
plain module-local singleton — no globalThis, no Symbol.for — and the
survivor module drops out of the bundle entirely. Verified: gatewayRegistryState,
gatewaySurvivor, and import.meta.hot are all absent from the prod build.

Removes the now-unused hmrActive() export.
2026-07-23 02:15:25 -05:00
Brooklyn Nicholson
1943c1b750 fix(desktop): dedup profile in HMR adoptBoot 2026-07-23 02:15:25 -05:00
Brooklyn Nicholson
813ddb4555 fix(desktop): keep gateway session alive across Vite HMR
Park the live primary gateway socket on Fast Refresh dispose and re-adopt it
on remount so dev UI edits don't tear down the WebSocket. Hold gateway store
singletons on globalThis + self-accept HMR on store/gateway.ts. Prod strips
import.meta.hot — live unmount unchanged.
2026-07-23 02:15:25 -05:00
hermes-seaeye[bot]
7c9d05267c
fmt(js): npm run fix on merge (#69939)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-23 07:08:53 +00:00
brooklyn!
3e163d29bd
Merge pull request #69936 from NousResearch/bb/voice-speak-whole-turn
fix(voice): speak the whole desktop turn and idle-flush held narration
2026-07-23 02:00:30 -05:00
Brooklyn Nicholson
9859e1f7df fix(voice): speak the whole turn, not just its first bubble; idle-flush held narration
Two fixes for desktop hands-free voice:

- The live speech session bound to the first assistant bubble with text, so
  a tool-calling turn spoke only the opening narration and silently dropped
  every later interim AND the final answer. The conversation selector now
  aggregates all unspoken assistant bubbles in order (turn-scoped speech);
  auto-speak keeps its latest-reply-only behavior.

- The speak-stream WS producer blocked forever on the text queue, so a
  narration line with no trailing whitespace ("Let me check.") sat in the
  sentence chunker until end-of-turn — spoken long after the tool finished,
  with the UI stuck on "Preparing audio…". Mirror the CLI speaker's idle
  flush: sentence-terminated buffers flush after 0.5s of producer silence,
  anything else after ~2s; open <think> blocks are never flushed.
2026-07-23 01:53:37 -05:00
brooklyn!
7168845dc3
Merge pull request #69909 from NousResearch/bb/computer-use-perf
perf(computer_use): cap capture size and cache vision routing
2026-07-23 01:49:05 -05:00
Brooklyn Nicholson
cc1765dce2 Merge remote-tracking branch 'origin/main' into bb/computer-use-perf
# Conflicts:
#	hermes_cli/config.py
#	tools/computer_use/cua_backend.py
2026-07-23 01:39:21 -05:00
brooklyn!
93f8da55cb
Merge pull request #69903 from NousResearch/bb/salvage-53841-no-overlay
fix(computer_use): disable cua-driver overlay by default on macOS/WSL (supersedes #53841)
2026-07-23 01:36:49 -05:00
brooklyn!
d3d989c4c2
feat(desktop): show credit-usage notices as toasts (#69808) (#69828)
* fix(desktop): render agent credit notices as toasts (#69808)

The desktop renderer had no handler for the `notification.show` /
`notification.clear` WS events, so every credit-usage notice the backend
sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently
dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never
appeared, even though the Ink TUI renders them in its status bar.

Add the two missing branches to the gateway-event dispatcher, delegating
to a small, pure-testable module:

- `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast
  (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the
  notice `key` as the toast id. Re-emitting the same key REPLACES the
  toast, so the credits 50→75→90 line escalates in place instead of
  stacking, and a key-matched `notification.clear` maps straight to
  `dismissNotification(key)`.
- The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast
  icon is added.
- Notices are account-wide, so the toast shows regardless of which
  session is focused.

The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the
latest-wins / sticky-vs-ttl / key-matched-clear behavior.

Export `NotificationInput` so the mapping's return type can be named.

* feat(desktop): native OS credit alerts + billing-page nudge (#69808)

Round out the credit-notice handling from the previous commit with the two
optional pieces from the issue:

- Native OS notification for the urgent pair. `credits.depleted` /
  `credits.restored` also fire an Electron notification when Hermes is
  backgrounded, via a new `credits` NativeNotificationKind (the existing
  five didn't fit) with its own toggle in Settings → Notifications (the
  panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and
  i18n are the only additions). The escalating usage line and grant-spent
  notice stay in-app toasts only. Dispatch is `global` (account-wide, not
  session-bound) and gated by the user's prefs + backgrounded check.
- Billing-page nudge. A `credits.*` crossing invalidates the
  `['billing','state']` query so Settings → Billing reflects the change
  immediately instead of waiting up to 30s for its poll.

`nativeNoticeInput()` is a pure mapping (urgent-key gate → native input),
unit-tested directly; the gateway-event branch does the localized-title
lookup and gated dispatch. i18n added for all four locales.

* feat(credits): report $used of $cap instead of % in the usage notice

The usage gauge is Nous subscription-cap-only (used_fraction requires a cap;
non-Nous providers emit no headers, so no notice fires). A bare percentage
implied a universal unit that doesn't exist, so report the absolute dollars
used of the cap instead: used = cap - remaining, from micros (money-safe),
clamped to [0, cap]. Still a snapshot at band-crossing (re-emits on band
change, not every turn) to keep the single escalating line and stay quiet on
append-only surfaces (messaging pushes one message per crossing).

* fix(desktop): de-dupe credit toast icon, band-color the figure, split detail

Three fixes to how agent credit notices render as toasts:
- Strip the leading severity glyph (the toast already draws a kind icon, so the
  raw text doubled it). Native OS notifications keep the glyph (no icon there).
- Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem.
- Band-color the $used figure (semibold) by $used/$cap: muted <75%,
  --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the
  existing --ui-* usage palette. Icon shares the accent.
- Split a trailing '. detail' into a muted secondary line (title+description
  convention) instead of an inline middot.
Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully
when a notice has no figure.

* fix(desktop): billing page always fetches fresh state (team-account desync)

The x-nous-credits-* headers are best-effort and can drift out of sync,
notably in team/org accounts where another member's spend moves the shared
balance without touching this client's headers. The billing endpoint is the
source of truth, so the page no longer trusts a cache: staleTime 0 +
refetchOnMount 'always' force a fresh fetch on every open and focus (still
polling 30s while mounted). The credits.* invalidation nudge still pulls a
crossing in immediately.

* chore(desktop): dev-only credit-notice demo hotkey

Ctrl+Shift+C (and window.__creditsDemo()) steps the full credit-notice
lifecycle (usage 50->75->90, grant-spent, depleted/restored) through the real
gateway event fan-out via a new emitLocalGatewayEvent, so the toast/native/
billing-invalidation paths are testable without hitting real usage bands.
Installed only under import.meta.env.DEV, so it's tree-shaken from production.
2026-07-23 01:36:28 -05:00
Brooklyn Nicholson
8cfc9e4e27 chore(desktop): dev-only credit-notice demo hotkey
Ctrl+Shift+C (and window.__creditsDemo()) steps the full credit-notice
lifecycle (usage 50->75->90, grant-spent, depleted/restored) through the real
gateway event fan-out via a new emitLocalGatewayEvent, so the toast/native/
billing-invalidation paths are testable without hitting real usage bands.
Installed only under import.meta.env.DEV, so it's tree-shaken from production.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson
664b131c30 fix(desktop): billing page always fetches fresh state (team-account desync)
The x-nous-credits-* headers are best-effort and can drift out of sync,
notably in team/org accounts where another member's spend moves the shared
balance without touching this client's headers. The billing endpoint is the
source of truth, so the page no longer trusts a cache: staleTime 0 +
refetchOnMount 'always' force a fresh fetch on every open and focus (still
polling 30s while mounted). The credits.* invalidation nudge still pulls a
crossing in immediately.
2026-07-23 01:28:59 -05:00
Brooklyn Nicholson
d010220588 fix(desktop): de-dupe credit toast icon, band-color the figure, split detail
Three fixes to how agent credit notices render as toasts:
- Strip the leading severity glyph (the toast already draws a kind icon, so the
  raw text doubled it). Native OS notifications keep the glyph (no icon there).
- Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem.
- Band-color the $used figure (semibold) by $used/$cap: muted <75%,
  --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the
  existing --ui-* usage palette. Icon shares the accent.
- Split a trailing '. detail' into a muted secondary line (title+description
  convention) instead of an inline middot.
Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully
when a notice has no figure.
2026-07-23 01:28:59 -05:00