Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3ea8a148 | ||
|
|
6ce6054dfa | ||
|
|
e942321cd7 | ||
|
|
365c23c554 | ||
|
|
25e78f129f | ||
|
|
b5152ec846 | ||
|
|
7bd3715f1d | ||
|
|
9f27f159cc | ||
|
|
eceb91cffd | ||
|
|
52134078e3 | ||
|
|
b6abd39ca5 | ||
|
|
d1b1308e2a | ||
|
|
a0aa1bd11c | ||
|
|
d1ecebcbfd | ||
|
|
db44af004c | ||
|
|
1b962f001e | ||
|
|
9137b86a52 | ||
|
|
7493de7fc3 | ||
|
|
1039e90b5e | ||
|
|
5cd2f6bd36 | ||
|
|
8ed16a7a0c | ||
|
|
3f80bcac56 | ||
|
|
01ae9b853e | ||
|
|
db01910e3a | ||
|
|
b7fa62c530 | ||
|
|
f4ef70f6fc | ||
|
|
bbc842d31e | ||
|
|
28f92478e3 | ||
|
|
e76e7b5073 | ||
|
|
8fa562a399 | ||
|
|
44e5848e74 | ||
|
|
6ebc449915 | ||
|
|
f6a42b1acf | ||
|
|
b2da39a0f3 | ||
|
|
17251e865b | ||
|
|
658ac1d866 | ||
|
|
c2c55c4443 | ||
|
|
e3adbb5ae9 | ||
|
|
e236bb87eb | ||
|
|
cf52370253 | ||
|
|
d7668aaff5 | ||
|
|
5094325140 | ||
|
|
c6e99ab375 | ||
|
|
80e4b8985e | ||
|
|
7d938cc5c9 | ||
|
|
cb6b4127e7 | ||
|
|
ee7b8a4672 | ||
|
|
630b43892d | ||
|
|
dd0e3e0a05 | ||
|
|
a0ec4f52b9 | ||
|
|
0e81d2fb71 | ||
|
|
989d5d0cb7 | ||
|
|
c92a95a130 | ||
|
|
4bc20abbd0 | ||
|
|
0586c8073e | ||
|
|
c176054c33 | ||
|
|
fdcfa44584 | ||
|
|
2572617d5a | ||
|
|
4db86e349c | ||
|
|
db00cbfd56 | ||
|
|
6681bef707 |
+11
-21
@@ -1,14 +1,12 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# (EOL April 2026), so we copy node + npm + corepack from the upstream node:26
|
||||
# image instead. Node 26 (Current; LTS promotion ~Oct 2026) is REQUIRED by the
|
||||
# native OpenTUI TUI engine, which loads its renderer via the experimental
|
||||
# `node:ffi` API that only exists on Node 26.3+ (the Ink engine + web build run
|
||||
# on it too). Bookworm-based slim image used so the produced binary links
|
||||
# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc 2.41)
|
||||
# runtime. The pinned tag ships v26.3.0. Bumping Node is a one-line change here.
|
||||
# NOTE: verify the full image build + Ink/web/Playwright on Node 26 in CI.
|
||||
FROM node:26-bookworm-slim@sha256:79723b41edbedf595f62e943a9f8b0ba9af5b1e61045c5f8f59c2c02c1212a16 AS node_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
# upstream node:22 image instead so we can stay on a supported LTS without
|
||||
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
|
||||
# so the produced binary links against glibc 2.36, which runs cleanly on
|
||||
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
|
||||
# is a one-line ARG change; see #4977.
|
||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
@@ -92,7 +90,7 @@ RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
||||
|
||||
# Node 26: copy the node binary plus the bundled npm + corepack JS
|
||||
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
|
||||
# installs from the upstream image. npm and npx are recreated as symlinks
|
||||
# because they're symlinks in the source image (and need to live on PATH).
|
||||
# See node_source stage at the top of the file for the version-bump
|
||||
@@ -121,7 +119,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
# what the image ships now (via the node:26 source stage). We set it
|
||||
# what the image ships now (via the node:22 source stage). We set it
|
||||
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
||||
# 9.x defaulted to install-as-copy, which produced a hidden
|
||||
# node_modules/.package-lock.json that permanently disagreed with the root
|
||||
@@ -183,16 +181,8 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
||||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
COPY ui-opentui/ ui-opentui/
|
||||
# ui-opentui is the opt-in native OpenTUI engine (HERMES_TUI_ENGINE=opentui;
|
||||
# default stays Ink). .dockerignore strips its node_modules/dist, so install +
|
||||
# esbuild-build it here -> dist/main.js, then prune devDeps (esbuild/babel/
|
||||
# vitest); the runtime only needs the prod deps (the external @opentui/core +
|
||||
# its native blob -- the bundle inlines solid/effect). Build needs Node 26.3
|
||||
# (node:ffi floor), which this image ships.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build && \
|
||||
cd ../ui-opentui && npm install --no-audit --no-fund && npm run build && npm prune --omit=dev
|
||||
cd ../ui-tui && npm run build
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
|
||||
@@ -107,8 +107,6 @@ You can still bring your own keys per-tool whenever you want — the gateway is
|
||||
|
||||
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
|
||||
|
||||
> **TUI engine:** On supported hosts (Linux/macOS with Node 26.3+), the terminal UI defaults to the native **OpenTUI** engine, which the installer provisions for you. The legacy **Ink** engine remains the fallback — it's used automatically on Windows, Termux, or when the native engine can't run, and you can select it explicitly with `HERMES_TUI_ENGINE=ink hermes`. Ink is not going away; it's the kept fallback.
|
||||
|
||||
| Action | CLI | Messaging platforms |
|
||||
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
|
||||
+3
-1
@@ -27,7 +27,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from agent.context_compressor import ContextCompressor
|
||||
@@ -195,6 +195,7 @@ def init_agent(
|
||||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
@@ -426,6 +427,7 @@ def init_agent(
|
||||
agent.status_callback = status_callback
|
||||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.event_callback = event_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
|
||||
@@ -603,6 +603,20 @@ def compress_context(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
|
||||
# the completed old session before its details are lost.
|
||||
_old_sid_for_event = locals().get("old_session_id")
|
||||
if getattr(agent, "event_callback", None):
|
||||
try:
|
||||
agent.event_callback("session:compress", {
|
||||
"platform": agent.platform or "",
|
||||
"session_id": agent.session_id,
|
||||
"old_session_id": _old_sid_for_event or "",
|
||||
"compression_count": agent.context_compressor.compression_count,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("event_callback error on session:compress: %s", e)
|
||||
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
|
||||
@@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
||||
agent.session_id, exc,
|
||||
)
|
||||
|
||||
if stored_prompt:
|
||||
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
|
||||
# Continuing session — reuse the exact system prompt from the
|
||||
# previous turn so the Anthropic cache prefix matches.
|
||||
agent._cached_system_prompt = stored_prompt
|
||||
return
|
||||
if stored_prompt:
|
||||
stored_state = "stale_runtime"
|
||||
logger.info(
|
||||
"Stored system prompt for session %s has stale runtime identity; "
|
||||
"rebuilding for model=%s provider=%s.",
|
||||
agent.session_id,
|
||||
getattr(agent, "model", "") or "",
|
||||
getattr(agent, "provider", "") or "",
|
||||
)
|
||||
|
||||
if conversation_history and stored_state in ("null", "empty"):
|
||||
# Continuing session whose stored prompt is unusable. The
|
||||
@@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
||||
)
|
||||
|
||||
|
||||
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
|
||||
"""Return False when the persisted Model/Provider lines are stale."""
|
||||
|
||||
def line_value(label: str) -> str:
|
||||
prefix = f"{label}:"
|
||||
value = ""
|
||||
for line in prompt.splitlines():
|
||||
if line.startswith(prefix):
|
||||
value = line[len(prefix):].strip()
|
||||
return value
|
||||
|
||||
stored_model = line_value("Model")
|
||||
current_model = str(getattr(agent, "model", "") or "").strip()
|
||||
if stored_model and current_model and stored_model != current_model:
|
||||
return False
|
||||
|
||||
stored_provider = line_value("Provider")
|
||||
current_provider = str(getattr(agent, "provider", "") or "").strip()
|
||||
if stored_provider and current_provider and stored_provider != current_provider:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
|
||||
if is_partial_stub and dropped_tools:
|
||||
tool_list = ", ".join(dropped_tools[:3])
|
||||
|
||||
+33
-2
@@ -33,6 +33,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.skill_commands import extract_user_instruction_from_skill_message
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -430,16 +431,37 @@ class MemoryManager:
|
||||
|
||||
# -- Prefetch / recall ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _strip_skill_scaffolding(text: str) -> Optional[str]:
|
||||
"""Return memory-worthy user text, or None to skip the turn.
|
||||
|
||||
When a user invokes a /skill or /bundle, Hermes expands the turn into
|
||||
a model-facing message that embeds the entire skill body. Feeding that
|
||||
verbatim to memory providers pollutes their stores/embeddings with
|
||||
prompt scaffolding instead of what the user actually asked. We recover
|
||||
just the user's instruction here, once, for every provider — so this
|
||||
is fixed for the whole provider fan-out, not per backend.
|
||||
|
||||
- Non-skill messages pass through unchanged.
|
||||
- Skill turns with a user instruction return that instruction.
|
||||
- Bare skill invocations (no instruction) return None → callers skip
|
||||
the turn, since there is no user content worth remembering.
|
||||
"""
|
||||
return extract_user_instruction_from_skill_message(text)
|
||||
|
||||
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
|
||||
"""Collect prefetch context from all providers.
|
||||
|
||||
Returns merged context text labeled by provider. Empty providers
|
||||
are skipped. Failures in one provider don't block others.
|
||||
"""
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return ""
|
||||
parts = []
|
||||
for provider in self._providers:
|
||||
try:
|
||||
result = provider.prefetch(query, session_id=session_id)
|
||||
result = provider.prefetch(clean_query, session_id=session_id)
|
||||
if result and result.strip():
|
||||
parts.append(result)
|
||||
except Exception as e:
|
||||
@@ -460,10 +482,14 @@ class MemoryManager:
|
||||
if not providers:
|
||||
return
|
||||
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
provider.queue_prefetch(query, session_id=session_id)
|
||||
provider.queue_prefetch(clean_query, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
|
||||
@@ -515,6 +541,11 @@ class MemoryManager:
|
||||
if not providers:
|
||||
return
|
||||
|
||||
clean_user_content = self._strip_skill_scaffolding(user_content)
|
||||
if not clean_user_content:
|
||||
return
|
||||
user_content = clean_user_content
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
|
||||
|
||||
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
|
||||
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
|
||||
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
|
||||
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
|
||||
Hermes infers the row taxonomy from the sheet and maps agent activity onto
|
||||
idle/run/review/failed/wave/jump.
|
||||
|
||||
This package is the **single source of truth** for the feature so the base
|
||||
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
|
||||
parts:
|
||||
|
||||
- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum.
|
||||
- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`.
|
||||
- :mod:`agent.pet.manifest` — fetch the public petdex manifest.
|
||||
- :mod:`agent.pet.store` — install / list / resolve pets on disk
|
||||
(profile-aware via ``get_hermes_home()``).
|
||||
- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a
|
||||
terminal (kitty / iTerm2 / sixel graphics
|
||||
protocols, with a Unicode half-block
|
||||
fallback).
|
||||
|
||||
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
|
||||
reuses the same on-disk store and the same state semantics.
|
||||
|
||||
The whole feature is a *display* concern: it adds no model tool, mutates no
|
||||
system prompt or toolset, and therefore has zero effect on prompt caching.
|
||||
"""
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
LOOP_MS,
|
||||
STATE_ROWS,
|
||||
PetState,
|
||||
)
|
||||
from agent.pet.state import derive_pet_state
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCALE",
|
||||
"FRAME_H",
|
||||
"FRAME_W",
|
||||
"FRAMES_PER_STATE",
|
||||
"LOOP_MS",
|
||||
"STATE_ROWS",
|
||||
"PetState",
|
||||
"derive_pet_state",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Pet sprite geometry + animation-state taxonomy.
|
||||
|
||||
These values are the common petdex/Codex pet geometry. The real ``pet.json``
|
||||
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
|
||||
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
|
||||
8-row sheets and current 9-row Codex sheets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
|
||||
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
|
||||
# (1728x1664). Renderers derive both row taxonomy and real column count from the
|
||||
# concrete sheet, so either shape works.
|
||||
FRAME_W = 192
|
||||
FRAME_H = 208
|
||||
|
||||
# Frames consumed per animation state (the petdex web app uses CSS
|
||||
# ``steps(6)``). A sheet may physically contain more columns; we only step
|
||||
# through the first ``FRAMES_PER_STATE``.
|
||||
FRAMES_PER_STATE = 6
|
||||
|
||||
# Full-loop duration for one state, milliseconds (petdex default).
|
||||
LOOP_MS = 1100
|
||||
|
||||
# Default on-screen scale relative to native frame size. ``display.pet.scale``
|
||||
# is the single master scalar: the desktop canvas multiplies its native pixels
|
||||
# by it and every terminal surface derives its half-block/kitty column width
|
||||
# from it (see :func:`cols_for_scale`), so one number shrinks all three
|
||||
# interfaces together. (petdex's own clients render at 0.7; we default smaller
|
||||
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
|
||||
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
|
||||
# legibility floor instead.)
|
||||
DEFAULT_SCALE = 0.33
|
||||
|
||||
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
|
||||
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
|
||||
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
|
||||
MIN_SCALE = 0.1
|
||||
MAX_SCALE = 3.0
|
||||
|
||||
|
||||
def clamp_scale(scale: float) -> float:
|
||||
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
|
||||
return max(MIN_SCALE, min(MAX_SCALE, scale))
|
||||
|
||||
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
|
||||
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
|
||||
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
|
||||
BASE_UNICODE_COLS = FRAME_W // 8
|
||||
|
||||
# Legibility floor for the half-block fallback. A half-block cell samples the
|
||||
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
|
||||
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
|
||||
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
|
||||
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
|
||||
# TO this floor (and grows it above), instead of past it into noise.
|
||||
UNICODE_MIN_COLS = 16
|
||||
|
||||
|
||||
def cols_for_scale(scale: float) -> int:
|
||||
"""Half-block width implied by *scale*, clamped to the legibility floor.
|
||||
|
||||
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
|
||||
renderers converge at larger sizes; below it the floor keeps the sprite
|
||||
readable rather than letting it devolve into a blob.
|
||||
"""
|
||||
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
|
||||
|
||||
|
||||
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
|
||||
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
|
||||
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
"""Animation state a pet can be shown in.
|
||||
|
||||
These are Hermes' activity state names. They are not always identical to the
|
||||
source atlas row names: Codex-format pets use rows like ``jumping`` /
|
||||
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
WAVE = "wave"
|
||||
RUN = "run"
|
||||
FAILED = "failed"
|
||||
REVIEW = "review"
|
||||
JUMP = "jump"
|
||||
WAITING = "waiting"
|
||||
|
||||
|
||||
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
|
||||
# 9-column atlas shape.
|
||||
LEGACY_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
PetState.WAVE.value,
|
||||
PetState.RUN.value,
|
||||
PetState.FAILED.value,
|
||||
PetState.REVIEW.value,
|
||||
PetState.JUMP.value,
|
||||
"extra1",
|
||||
"extra2",
|
||||
]
|
||||
|
||||
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
|
||||
# 8 columns x 9 rows of 192x208 cells.
|
||||
CODEX_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
PetState.FAILED.value,
|
||||
PetState.WAITING.value,
|
||||
"running",
|
||||
PetState.REVIEW.value,
|
||||
]
|
||||
|
||||
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
|
||||
# format because generated pets and the public Codex pet contract use it.
|
||||
STATE_ROWS: list[str] = CODEX_STATE_ROWS
|
||||
|
||||
# Canonical Hermes activity names -> accepted row-name aliases in descending
|
||||
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
|
||||
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
|
||||
STATE_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
PetState.IDLE.value: (PetState.IDLE.value,),
|
||||
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
|
||||
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
|
||||
PetState.RUN.value: (PetState.RUN.value, "running"),
|
||||
PetState.FAILED.value: (PetState.FAILED.value,),
|
||||
PetState.REVIEW.value: (PetState.REVIEW.value,),
|
||||
PetState.WAITING.value: (PetState.WAITING.value,),
|
||||
}
|
||||
|
||||
|
||||
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
|
||||
"""Return accepted row-name aliases for *state* (always non-empty)."""
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
aliases = STATE_ALIASES.get(value)
|
||||
return aliases if aliases else (value,)
|
||||
|
||||
|
||||
def state_rows_for_grid(row_count: int | None) -> list[str]:
|
||||
"""Return the row taxonomy for a spritesheet with *row_count* rows."""
|
||||
try:
|
||||
rows = int(row_count or 0)
|
||||
except (TypeError, ValueError):
|
||||
rows = 0
|
||||
|
||||
if rows >= len(CODEX_STATE_ROWS):
|
||||
return CODEX_STATE_ROWS
|
||||
return LEGACY_STATE_ROWS
|
||||
|
||||
|
||||
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
|
||||
"""Return the spritesheet row index for *state* (clamped, never raises)."""
|
||||
rows = state_rows_for_grid(row_count)
|
||||
for name in state_aliases_for(state):
|
||||
try:
|
||||
return rows.index(name)
|
||||
except ValueError:
|
||||
continue
|
||||
return 0 # fall back to the idle row
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Fetch the public petdex manifest.
|
||||
|
||||
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
|
||||
|
||||
{
|
||||
"generatedAt": "...",
|
||||
"total": 2926,
|
||||
"pets": [
|
||||
{"slug": "boba", "displayName": "Boba", "kind": "creature",
|
||||
"submittedBy": "railly",
|
||||
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
|
||||
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
|
||||
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Read-only and unauthenticated; no credentials involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_URL = "https://petdex.dev/api/manifest"
|
||||
|
||||
_DEFAULT_TIMEOUT = 20.0
|
||||
|
||||
# In-process cache for the (large, slow, identical-per-call) manifest. The list
|
||||
# is a static CDN object that barely changes, yet a single session can ask for
|
||||
# it many times — every gallery open, plus a full re-fetch per install/select
|
||||
# (``find_entry``). A short TTL collapses those into one network hit without
|
||||
# going stale for long. Cleared by :func:`clear_cache` (tests).
|
||||
_MANIFEST_TTL = 300.0
|
||||
_cache: tuple[float, list[ManifestEntry]] | None = None
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the cached manifest (forces the next fetch to hit the network)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestEntry:
|
||||
"""A single pet's row in the manifest."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
kind: str
|
||||
submitted_by: str
|
||||
spritesheet_url: str
|
||||
pet_json_url: str
|
||||
zip_url: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ManifestEntry":
|
||||
return cls(
|
||||
slug=str(data.get("slug", "")).strip(),
|
||||
display_name=str(data.get("displayName", "") or data.get("slug", "")),
|
||||
kind=str(data.get("kind", "") or "pet"),
|
||||
submitted_by=str(data.get("submittedBy", "") or ""),
|
||||
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
|
||||
pet_json_url=str(data.get("petJsonUrl", "") or ""),
|
||||
zip_url=str(data.get("zipUrl", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
class ManifestError(RuntimeError):
|
||||
"""Raised when the manifest can't be fetched or parsed."""
|
||||
|
||||
|
||||
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
|
||||
"""Return every approved pet from the public manifest.
|
||||
|
||||
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
|
||||
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
|
||||
any network/parse failure so callers can surface a clean message.
|
||||
"""
|
||||
global _cache
|
||||
|
||||
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
|
||||
return _cache[1]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError as exc: # pragma: no cover - httpx is a core dep
|
||||
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
MANIFEST_URL,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
|
||||
|
||||
pets = payload.get("pets") if isinstance(payload, dict) else None
|
||||
if not isinstance(pets, list):
|
||||
raise ManifestError("petdex manifest had no 'pets' array")
|
||||
|
||||
entries: list[ManifestEntry] = []
|
||||
for raw in pets:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
entry = ManifestEntry.from_dict(raw)
|
||||
if entry.slug and entry.spritesheet_url:
|
||||
entries.append(entry)
|
||||
|
||||
_cache = (time.monotonic(), entries)
|
||||
return entries
|
||||
|
||||
|
||||
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
|
||||
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
|
||||
slug = slug.strip().lower()
|
||||
for entry in fetch_manifest(timeout=timeout):
|
||||
if entry.slug.lower() == slug:
|
||||
return entry
|
||||
return None
|
||||
@@ -0,0 +1,618 @@
|
||||
"""Decode a pet spritesheet and encode frames for a terminal.
|
||||
|
||||
Shared by the base CLI (writes the escape bytes to its own stdout) and the
|
||||
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
|
||||
decode + capability-detection + protocol-encoding logic exists exactly once.
|
||||
|
||||
Supported output modes, in fidelity order:
|
||||
|
||||
- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm).
|
||||
- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm).
|
||||
- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …).
|
||||
- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal.
|
||||
|
||||
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
|
||||
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
|
||||
empty string rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
PetState,
|
||||
state_row_index,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public render-mode names accepted by ``display.pet.render_mode``.
|
||||
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Terminal capability detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def detect_terminal_graphics() -> str:
|
||||
"""Best-effort detection of the richest graphics protocol available.
|
||||
|
||||
Env-based (non-blocking — we never issue a DA1/terminal query that could
|
||||
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
|
||||
``unicode``. Conservative: unknown terminals get ``unicode``, which works
|
||||
anywhere with truecolor.
|
||||
"""
|
||||
term = os.environ.get("TERM", "").lower()
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
|
||||
# authoritatively but does NOT scrub the terminal env vars it inherits when
|
||||
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
|
||||
# Trusting those leaks emits an image protocol the embedded xterm.js can't
|
||||
# display — you get a blank frame. Inline images there are opt-in
|
||||
# (terminal.integrated.enableImages), so default to half-blocks, which
|
||||
# always render in its truecolor grid. Users who enabled images can pin
|
||||
# display.pet.render_mode explicitly.
|
||||
if term_program == "vscode":
|
||||
return "unicode"
|
||||
|
||||
# kitty graphics protocol
|
||||
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
|
||||
return "kitty"
|
||||
if term_program in {"ghostty"}:
|
||||
return "kitty"
|
||||
|
||||
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
|
||||
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
|
||||
return "kitty"
|
||||
|
||||
# iTerm2 inline images
|
||||
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
|
||||
return "iterm"
|
||||
|
||||
# sixel-capable terminals (env heuristics only)
|
||||
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
|
||||
return "sixel"
|
||||
if "sixel" in term:
|
||||
return "sixel"
|
||||
|
||||
return "unicode"
|
||||
|
||||
|
||||
def resolve_mode(configured: str | None, *, stream=None) -> str:
|
||||
"""Resolve the effective render mode from config + the environment.
|
||||
|
||||
``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns
|
||||
``off`` when not attached to a TTY (no point emitting graphics into a pipe
|
||||
or logfile).
|
||||
"""
|
||||
mode = (configured or "auto").strip().lower()
|
||||
if mode not in RENDER_MODES:
|
||||
mode = "auto"
|
||||
if mode == "off":
|
||||
return "off"
|
||||
|
||||
stream = stream or sys.stdout
|
||||
try:
|
||||
if not (hasattr(stream, "isatty") and stream.isatty()):
|
||||
return "off"
|
||||
except (ValueError, OSError):
|
||||
return "off"
|
||||
|
||||
if mode == "auto":
|
||||
return detect_terminal_graphics()
|
||||
return mode
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Frame decoding
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_sheet(path: Path):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
return img.convert("RGBA")
|
||||
|
||||
|
||||
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
|
||||
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
|
||||
# the trailing columns with fully transparent cells. Animating into one flashes
|
||||
# the pet blank, so we stop the row at the first such gap.
|
||||
_BLANK_ALPHA = 8
|
||||
|
||||
|
||||
def _frame_is_blank(frame) -> bool:
|
||||
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
|
||||
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _raw_frames(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
) -> tuple:
|
||||
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
|
||||
|
||||
Steps across the row until the first blank column so pets with ragged
|
||||
per-state frame counts never animate into empty padding. Cached; returns
|
||||
``()`` on any decode failure.
|
||||
"""
|
||||
try:
|
||||
sheet = _open_sheet(Path(sheet_path))
|
||||
cols = max(1, sheet.width // frame_w)
|
||||
rows = max(1, sheet.height // frame_h)
|
||||
row = state_row_index(state_value, rows)
|
||||
top = row * frame_h
|
||||
# Clamp the row to the sheet (some pets ship fewer rows than the 8 the
|
||||
# taxonomy reserves).
|
||||
if top + frame_h > sheet.height:
|
||||
top = max(0, sheet.height - frame_h)
|
||||
|
||||
frames = []
|
||||
for i in range(min(frames_per_state, cols)):
|
||||
left = i * frame_w
|
||||
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
|
||||
if _frame_is_blank(frame):
|
||||
break # trailing transparent padding — real frames end here
|
||||
frames.append(frame)
|
||||
return tuple(frames)
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
|
||||
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
|
||||
return ()
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _frames_for(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
scale_w: int,
|
||||
scale_h: int,
|
||||
):
|
||||
"""Return padding-trimmed RGBA frames for one state row, scaled.
|
||||
|
||||
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
|
||||
frame requests during animation are free.
|
||||
"""
|
||||
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
|
||||
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
|
||||
return list(raw)
|
||||
from PIL import Image
|
||||
|
||||
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
|
||||
|
||||
|
||||
def state_frame_counts(
|
||||
sheet_path: str | Path,
|
||||
*,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> dict[str, int]:
|
||||
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
|
||||
|
||||
The single source of truth for "how many frames does this state actually
|
||||
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
|
||||
ships this map to the desktop canvas, which steps its own loop.
|
||||
"""
|
||||
return {
|
||||
state.value: len(
|
||||
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
|
||||
)
|
||||
for state in PetState
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Encoders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _png_bytes(frame) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _kitty_apc(ctrl: str, data: str) -> str:
|
||||
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
|
||||
chunk = 4096
|
||||
if len(data) <= chunk:
|
||||
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
|
||||
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
|
||||
rest = data[chunk:]
|
||||
while rest:
|
||||
piece, rest = rest[:chunk], rest[chunk:]
|
||||
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame via the kitty graphics protocol (transmit + display).
|
||||
|
||||
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
|
||||
box in terminal cells so successive frames overwrite the same area.
|
||||
"""
|
||||
ctrl = "f=100,a=T,q=2"
|
||||
if cell_cols:
|
||||
ctrl += f",c={cell_cols}"
|
||||
if cell_rows:
|
||||
ctrl += f",r={cell_rows}"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# kitty Unicode placeholders
|
||||
#
|
||||
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
|
||||
# cell's width, so it can't host raw kitty image escapes (no width to count,
|
||||
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
|
||||
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
|
||||
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
|
||||
# foreground color encodes the image id. Ink counts those as width-1 text, so
|
||||
# layout stays correct and the terminal paints the image underneath.
|
||||
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_KITTY_PLACEHOLDER = "\U0010eeee"
|
||||
|
||||
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
|
||||
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
|
||||
# the diacritic that encodes the number i; we only ever need the row index.
|
||||
_ROWCOL_DIACRITICS: tuple[int, ...] = (
|
||||
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
|
||||
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
|
||||
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
|
||||
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
|
||||
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
|
||||
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
|
||||
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
|
||||
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
|
||||
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
|
||||
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
|
||||
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
|
||||
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
||||
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
|
||||
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
|
||||
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
|
||||
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
|
||||
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
|
||||
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
|
||||
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
|
||||
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
|
||||
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
|
||||
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
|
||||
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
|
||||
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
||||
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
|
||||
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
|
||||
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
|
||||
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
|
||||
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
|
||||
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
|
||||
)
|
||||
|
||||
|
||||
def kitty_image_id(slug: str) -> int:
|
||||
"""Stable per-pet image id in ``[1, 0x7FFF]``.
|
||||
|
||||
The id is encoded in the placeholder's 24-bit foreground color, so it must
|
||||
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
|
||||
deterministic per slug (so re-renders reuse the same terminal-side image)
|
||||
while making collisions between two different pets unlikely.
|
||||
"""
|
||||
import zlib
|
||||
|
||||
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
|
||||
|
||||
|
||||
def kitty_color_hex(image_id: int) -> str:
|
||||
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
|
||||
return "#%06x" % (image_id & 0xFFFFFF)
|
||||
|
||||
|
||||
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
|
||||
"""Build the placeholder text grid for an *rows*×*cols* image.
|
||||
|
||||
Each line is one row of the grid: the first cell carries the row diacritic
|
||||
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
|
||||
the terminal auto-increment the column. The foreground color (the image id)
|
||||
is applied by the caller / Ink, not embedded here.
|
||||
"""
|
||||
cols = max(1, cols)
|
||||
out: list[str] = []
|
||||
for r in range(max(1, rows)):
|
||||
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
|
||||
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
|
||||
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
|
||||
return out
|
||||
|
||||
|
||||
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
|
||||
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
|
||||
|
||||
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
|
||||
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
|
||||
terminal's OK/error replies that would otherwise corrupt the host app's
|
||||
output. Re-sending with the same ``i`` replaces the image, so the static
|
||||
placeholder cells animate underneath.
|
||||
"""
|
||||
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
|
||||
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
|
||||
size = len(payload)
|
||||
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
|
||||
if cell_cols:
|
||||
args.append(f"width={cell_cols}")
|
||||
if cell_rows:
|
||||
args.append(f"height={cell_rows}")
|
||||
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
|
||||
|
||||
|
||||
def _encode_sixel(frame) -> str:
|
||||
"""Encode one frame as DEC sixel.
|
||||
|
||||
Quantizes to an adaptive palette (≤255 colors) and emits the sixel band
|
||||
stream. Pillow has no sixel writer, so this is a compact hand-rolled
|
||||
encoder. Transparent pixels render as background (color register skipped).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
rgba = frame
|
||||
# Composite onto transparent-as-skip: track alpha to decide background.
|
||||
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
|
||||
palette = pal.getpalette() or []
|
||||
px = pal.load()
|
||||
alpha = rgba.getchannel("A").load()
|
||||
w, h = pal.size
|
||||
|
||||
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
|
||||
# Color register definitions (sixel uses 0..100 scale).
|
||||
used = sorted({px[x, y] for y in range(h) for x in range(w)})
|
||||
for idx in used:
|
||||
r = palette[idx * 3] if idx * 3 < len(palette) else 0
|
||||
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
|
||||
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
|
||||
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
|
||||
|
||||
# Emit in 6-row bands.
|
||||
for band in range(0, h, 6):
|
||||
for color_idx in used:
|
||||
line = ["#%d" % color_idx]
|
||||
run_char = None
|
||||
run_len = 0
|
||||
|
||||
def flush():
|
||||
nonlocal run_char, run_len
|
||||
if run_char is None:
|
||||
return
|
||||
if run_len > 3:
|
||||
line.append("!%d%s" % (run_len, run_char))
|
||||
else:
|
||||
line.append(run_char * run_len)
|
||||
run_char, run_len = None, 0
|
||||
|
||||
for x in range(w):
|
||||
bits = 0
|
||||
for bit in range(6):
|
||||
y = band + bit
|
||||
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
|
||||
bits |= 1 << bit
|
||||
ch = chr(63 + bits)
|
||||
if ch == run_char:
|
||||
run_len += 1
|
||||
else:
|
||||
flush()
|
||||
run_char, run_len = ch, 1
|
||||
flush()
|
||||
out.append("".join(line) + "$") # carriage return within band
|
||||
out.append("-") # next band
|
||||
out.append("\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_HALF_BLOCK = "▀"
|
||||
|
||||
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
|
||||
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||
|
||||
|
||||
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
|
||||
"""Downscale a frame to a grid of half-block cells.
|
||||
|
||||
Each cell pairs a top and bottom pixel so one terminal row encodes two
|
||||
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the
|
||||
framework-neutral representation shared by the ANSI encoder (CLI) and the
|
||||
structured ``cells`` API (Ink).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
target_cols = max(4, target_cols)
|
||||
aspect = frame.height / max(1, frame.width)
|
||||
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
|
||||
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
|
||||
px = small.load()
|
||||
|
||||
grid: list[list[Cell]] = []
|
||||
for y in range(0, target_rows, 2):
|
||||
row: list[Cell] = []
|
||||
for x in range(target_cols):
|
||||
top = px[x, y]
|
||||
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
|
||||
row.append((top, bottom))
|
||||
grid.append(row)
|
||||
return grid
|
||||
|
||||
|
||||
def _encode_unicode(frame, *, target_cols: int) -> str:
|
||||
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
|
||||
lines: list[str] = []
|
||||
for row in _downscale_cells(frame, target_cols=target_cols):
|
||||
cells: list[str] = []
|
||||
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
|
||||
if ta < 32 and ba < 32:
|
||||
cells.append("\x1b[0m ") # fully transparent → blank
|
||||
continue
|
||||
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
|
||||
lines.append("".join(cells) + "\x1b[0m")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Public renderer
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PetRenderer:
|
||||
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
|
||||
|
||||
Construct once per pet, then call :meth:`frame` on an animation timer.
|
||||
Cheap to call repeatedly — decoded frames are cached.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
mode: str = "unicode",
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> None:
|
||||
self.spritesheet = str(spritesheet)
|
||||
self.mode = mode if mode in RENDER_MODES else "unicode"
|
||||
self.scale = scale
|
||||
self.unicode_cols = unicode_cols
|
||||
self.frame_w = frame_w
|
||||
self.frame_h = frame_h
|
||||
self.frames_per_state = frames_per_state
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.mode != "off" and Path(self.spritesheet).is_file()
|
||||
|
||||
def frame_count(self, state: PetState | str) -> int:
|
||||
return len(self._frames(state))
|
||||
|
||||
def _frames(self, state: PetState | str):
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
scale_w = max(1, int(self.frame_w * self.scale))
|
||||
scale_h = max(1, int(self.frame_h * self.scale))
|
||||
return _frames_for(
|
||||
self.spritesheet,
|
||||
value,
|
||||
self.frame_w,
|
||||
self.frame_h,
|
||||
self.frames_per_state,
|
||||
scale_w,
|
||||
scale_h,
|
||||
)
|
||||
|
||||
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
|
||||
"""Return one frame as a half-block cell grid (framework-neutral).
|
||||
|
||||
Used by the TUI, which renders the grid with native Ink color props
|
||||
instead of raw ANSI. Returns ``[]`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return []
|
||||
frame = frames[index % len(frames)]
|
||||
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
|
||||
|
||||
def _cell_box(self, frame) -> tuple[int, int]:
|
||||
"""Terminal cell box for a scaled frame (~8×16 px per cell).
|
||||
|
||||
Must match :meth:`frame` graphics sizing — kitty stretches the image to
|
||||
fill ``c``×``r`` cells, so these must reflect the scaled pixel
|
||||
dimensions, not a native-aspect column count (that upscales small pets).
|
||||
"""
|
||||
return max(1, frame.width // 8), max(1, frame.height // 16)
|
||||
|
||||
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
|
||||
"""Build the kitty Unicode-placeholder payload for one state.
|
||||
|
||||
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
|
||||
list of transmit escapes (one per animation frame, all reusing
|
||||
``image_id``) and ``placeholder`` is the static text grid Ink paints.
|
||||
Placement geometry is derived from the scaled frame pixels (via
|
||||
:meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill
|
||||
``c``×``r`` cells. ``None`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return None
|
||||
cols, rows = self._cell_box(frames[0])
|
||||
return {
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"placeholder": kitty_placeholder_rows(cols, rows),
|
||||
"frames": [
|
||||
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
|
||||
],
|
||||
}
|
||||
|
||||
def frame(self, state: PetState | str, index: int) -> str:
|
||||
"""Return the encoded escape string for one frame, or ``""``.
|
||||
|
||||
``index`` is taken modulo the available frame count so callers can pass
|
||||
a free-running counter.
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return ""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return ""
|
||||
frame = frames[index % len(frames)]
|
||||
cell_cols, cell_rows = self._cell_box(frame)
|
||||
|
||||
try:
|
||||
if self.mode == "kitty":
|
||||
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "iterm":
|
||||
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "sixel":
|
||||
return _encode_sixel(frame)
|
||||
return _encode_unicode(frame, target_cols=self.unicode_cols)
|
||||
except Exception as exc: # noqa: BLE001 - degrade silently
|
||||
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
|
||||
return ""
|
||||
|
||||
|
||||
def build_renderer(
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
configured_mode: str | None = None,
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
stream=None,
|
||||
) -> PetRenderer:
|
||||
"""Convenience factory: resolve the mode from config+env, then construct."""
|
||||
mode = resolve_mode(configured_mode, stream=stream)
|
||||
return PetRenderer(
|
||||
spritesheet,
|
||||
mode=mode,
|
||||
scale=scale,
|
||||
unicode_cols=unicode_cols,
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Map agent activity → a :class:`PetState`.
|
||||
|
||||
This is the one place the "what is the agent doing right now?" → "which
|
||||
animation row?" decision lives. Each surface feeds it the signals it already
|
||||
tracks:
|
||||
|
||||
- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
|
||||
- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events.
|
||||
- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores
|
||||
(re-implemented in TS, but mirroring this priority order).
|
||||
|
||||
Keeping the priority order here (and documenting it) lets the TypeScript
|
||||
mirror stay faithful without a second design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from agent.pet.constants import PetState
|
||||
|
||||
|
||||
def todos_all_done(todos: Iterable[Any] | None) -> bool:
|
||||
"""True iff there's ≥1 todo and every one is completed/cancelled.
|
||||
|
||||
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
|
||||
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
|
||||
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
|
||||
"""
|
||||
items = list(todos or [])
|
||||
if not items:
|
||||
return False
|
||||
|
||||
def _status(t: Any) -> Any:
|
||||
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
|
||||
|
||||
return all(_status(t) in ("completed", "cancelled") for t in items)
|
||||
|
||||
|
||||
def derive_pet_state(
|
||||
*,
|
||||
busy: bool = False,
|
||||
awaiting_input: bool = False,
|
||||
error: bool = False,
|
||||
celebrate: bool = False,
|
||||
just_completed: bool = False,
|
||||
tool_running: bool = False,
|
||||
reasoning: bool = False,
|
||||
) -> PetState:
|
||||
"""Resolve the animation state from coarse activity signals.
|
||||
|
||||
Priority (highest first) — only one row can show at a time, so the most
|
||||
salient signal wins:
|
||||
|
||||
1. ``error`` → ``FAILED`` (a tool/turn just failed)
|
||||
2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done)
|
||||
3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting)
|
||||
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval
|
||||
prompt is open; this outranks the in-flight signals below because the turn
|
||||
is paused on *you*, even though a tool is technically mid-call)
|
||||
5. ``tool_running`` → ``RUN`` (a tool is executing)
|
||||
6. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
|
||||
7. ``busy`` → ``RUN`` (turn in flight, unspecified work)
|
||||
8. otherwise → ``IDLE``
|
||||
"""
|
||||
if error:
|
||||
return PetState.FAILED
|
||||
if celebrate:
|
||||
return PetState.JUMP
|
||||
if just_completed:
|
||||
return PetState.WAVE
|
||||
if awaiting_input:
|
||||
return PetState.WAITING
|
||||
if tool_running:
|
||||
return PetState.RUN
|
||||
if reasoning:
|
||||
return PetState.REVIEW
|
||||
if busy:
|
||||
return PetState.RUN
|
||||
return PetState.IDLE
|
||||
@@ -0,0 +1,316 @@
|
||||
"""On-disk pet store — install / list / resolve pets.
|
||||
|
||||
Pets live under ``get_hermes_home()/pets/<slug>/`` so every profile gets its
|
||||
own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default —
|
||||
that's owned by the petdex npm CLI and isn't profile-aware). Each installed
|
||||
pet directory holds:
|
||||
|
||||
pets/<slug>/
|
||||
pet.json # {id, displayName, description, spritesheetPath}
|
||||
spritesheet.webp # (or .png)
|
||||
|
||||
The active pet is resolved from the caller-supplied ``display.pet.slug`` config
|
||||
value (falling back to the first installed pet), so this module stays free of
|
||||
the config loader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DOWNLOAD_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class PetStoreError(RuntimeError):
|
||||
"""Raised on install/IO failures."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledPet:
|
||||
"""A pet present on disk."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
description: str
|
||||
directory: Path
|
||||
spritesheet: Path
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.spritesheet.is_file()
|
||||
|
||||
|
||||
def pets_dir() -> Path:
|
||||
"""Return the profile-scoped pets directory (created on demand)."""
|
||||
path = get_hermes_home() / "pets"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _read_pet_json(directory: Path) -> dict:
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("unreadable pet.json in %s: %s", directory, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
|
||||
"""Find the spritesheet for a pet dir.
|
||||
|
||||
Honors ``spritesheetPath`` from pet.json, else probes the conventional
|
||||
filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``).
|
||||
"""
|
||||
declared = str(meta.get("spritesheetPath", "") or "").strip()
|
||||
if declared:
|
||||
candidate = directory / declared
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"):
|
||||
candidate = directory / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
# Default expectation even if missing, so callers get a stable path.
|
||||
return directory / "spritesheet.webp"
|
||||
|
||||
|
||||
def load_pet(slug: str) -> InstalledPet | None:
|
||||
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
|
||||
slug = slug.strip()
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return None
|
||||
meta = _read_pet_json(directory)
|
||||
return InstalledPet(
|
||||
slug=slug,
|
||||
display_name=str(meta.get("displayName", "") or slug),
|
||||
description=str(meta.get("description", "") or ""),
|
||||
directory=directory,
|
||||
spritesheet=_resolve_spritesheet(directory, meta),
|
||||
)
|
||||
|
||||
|
||||
def installed_pets() -> list[InstalledPet]:
|
||||
"""Return every installed pet (dirs containing a usable spritesheet)."""
|
||||
out: list[InstalledPet] = []
|
||||
for child in sorted(pets_dir().iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
pet = load_pet(child.name)
|
||||
if pet and pet.exists:
|
||||
out.append(pet)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
|
||||
"""Resolve which pet to display.
|
||||
|
||||
Precedence: the configured slug (``display.pet.slug``) if it's installed,
|
||||
otherwise the first installed pet alphabetically, otherwise ``None``.
|
||||
"""
|
||||
if configured_slug:
|
||||
pet = load_pet(configured_slug.strip())
|
||||
if pet and pet.exists:
|
||||
return pet
|
||||
pets = installed_pets()
|
||||
return pets[0] if pets else None
|
||||
|
||||
|
||||
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
|
||||
"""Download *slug* from the manifest into the pets directory.
|
||||
|
||||
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
|
||||
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on
|
||||
failure.
|
||||
"""
|
||||
from agent.pet.manifest import find_entry
|
||||
|
||||
slug = slug.strip()
|
||||
existing = load_pet(slug)
|
||||
if existing and existing.exists and not force:
|
||||
return existing
|
||||
|
||||
entry = find_entry(slug, timeout=timeout)
|
||||
if entry is None:
|
||||
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
|
||||
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
|
||||
sprite_path = directory / f"spritesheet{sprite_ext}"
|
||||
|
||||
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
|
||||
|
||||
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
|
||||
# one so the local layout is self-describing.
|
||||
meta: dict = {}
|
||||
if entry.pet_json_url:
|
||||
try:
|
||||
meta = _download_json(entry.pet_json_url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
|
||||
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
|
||||
if not isinstance(meta, dict) or not meta:
|
||||
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
|
||||
meta["spritesheetPath"] = sprite_path.name
|
||||
meta.setdefault("id", slug)
|
||||
meta.setdefault("displayName", entry.display_name)
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"install of '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
_THUMB_FRAME_W = 192
|
||||
_THUMB_FRAME_H = 208
|
||||
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
|
||||
|
||||
|
||||
def _thumbs_dir() -> Path:
|
||||
path = pets_dir() / ".thumbs"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _is_petdex_host(url: str) -> bool:
|
||||
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return host == "petdex.dev" or host.endswith(".petdex.dev")
|
||||
|
||||
|
||||
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
|
||||
"""Return a small idle-frame PNG for *slug*, cached on disk.
|
||||
|
||||
Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples
|
||||
it to a thumbnail. Source preference: an installed spritesheet on disk, else
|
||||
*source_url* — but only when it points at petdex (so the gateway never
|
||||
fetches an arbitrary client-supplied URL). Returns ``None`` when there's no
|
||||
usable source or Pillow/network fails; callers render a placeholder.
|
||||
|
||||
Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that
|
||||
break a direct ``<img src=cdn>`` and lets the result ride the authenticated
|
||||
gateway as a same-origin data URL.
|
||||
"""
|
||||
slug = slug.strip()
|
||||
if not slug:
|
||||
return None
|
||||
|
||||
cache = _thumbs_dir() / f"{slug}.png"
|
||||
if cache.is_file():
|
||||
try:
|
||||
return cache.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sheet_bytes: bytes | None = None
|
||||
pet = load_pet(slug)
|
||||
if pet and pet.exists:
|
||||
try:
|
||||
sheet_bytes = pet.spritesheet.read_bytes()
|
||||
except OSError:
|
||||
sheet_bytes = None
|
||||
|
||||
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
source_url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
sheet_bytes = resp.content
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
|
||||
logger.debug("thumb fetch failed for %s: %s", slug, exc)
|
||||
|
||||
if not sheet_bytes:
|
||||
return None
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(sheet_bytes)) as im:
|
||||
frame = im.convert("RGBA").crop(
|
||||
(0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height))
|
||||
)
|
||||
height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)
|
||||
frame = frame.resize((_THUMB_W, height), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
data = buf.getvalue()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("thumb crop failed for %s: %s", slug, exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
cache.write_bytes(data)
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def remove_pet(slug: str) -> bool:
|
||||
"""Delete an installed pet directory. Returns True if anything was removed."""
|
||||
import shutil
|
||||
|
||||
directory = pets_dir() / slug.strip()
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
return not directory.exists()
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: float) -> None:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET",
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
with tmp.open("wb") as fh:
|
||||
for chunk in resp.iter_bytes():
|
||||
fh.write(chunk)
|
||||
tmp.replace(dest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise PetStoreError(f"download failed for {url}: {exc}") from exc
|
||||
|
||||
|
||||
def _download_json(url: str, *, timeout: float) -> dict:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
+57
-1
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import contextvars
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
@@ -958,6 +959,52 @@ CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
|
||||
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
|
||||
|
||||
|
||||
def _get_context_file_max_chars() -> int:
|
||||
"""Return the configured context-file truncation limit.
|
||||
|
||||
``CONTEXT_FILE_MAX_CHARS`` remains the upstream-compatible default and
|
||||
fallback. Users with larger context windows can raise
|
||||
``context_file_max_chars`` in config.yaml without patching Hermes.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
val = load_config().get("context_file_max_chars")
|
||||
if isinstance(val, (int, float)) and val > 0:
|
||||
return int(val)
|
||||
except Exception as e:
|
||||
logger.debug("Could not read context_file_max_chars from config: %s", e)
|
||||
return CONTEXT_FILE_MAX_CHARS
|
||||
|
||||
# Collect truncation warnings so the caller (run_agent) can surface them.
|
||||
# A ContextVar (not a module-global list) isolates accumulation per thread /
|
||||
# per async task, so concurrent gateway-session prompt builds can't drain or
|
||||
# clear each other's pending warnings (cross-session leak). Each build runs in
|
||||
# its own context, collects its own warnings, and drains them synchronously.
|
||||
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
|
||||
"context_file_truncation_warnings", default=None
|
||||
)
|
||||
|
||||
|
||||
def _record_truncation_warning(msg: str) -> None:
|
||||
"""Append a truncation warning to the current context's accumulator."""
|
||||
warnings = _truncation_warnings.get()
|
||||
if warnings is None:
|
||||
warnings = []
|
||||
_truncation_warnings.set(warnings)
|
||||
warnings.append(msg)
|
||||
|
||||
|
||||
def drain_truncation_warnings() -> list:
|
||||
"""Return and clear any truncation warnings accumulated in this context."""
|
||||
warnings = _truncation_warnings.get()
|
||||
if not warnings:
|
||||
return []
|
||||
drained = list(warnings)
|
||||
warnings.clear()
|
||||
return drained
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Skills prompt cache
|
||||
# =========================================================================
|
||||
@@ -1463,10 +1510,19 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
||||
# =========================================================================
|
||||
|
||||
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
|
||||
def _truncate_content(content: str, filename: str, max_chars: Optional[int] = None) -> str:
|
||||
"""Head/tail truncation with a marker in the middle."""
|
||||
if max_chars is None:
|
||||
max_chars = _get_context_file_max_chars()
|
||||
if len(content) <= max_chars:
|
||||
return content
|
||||
msg = (
|
||||
f"⚠️ Context file {filename} TRUNCATED: "
|
||||
f"{len(content)} chars exceeds limit of {max_chars} — "
|
||||
f"increase context_file_max_chars or trim the file!"
|
||||
)
|
||||
logger.warning(msg)
|
||||
_record_truncation_warning(msg)
|
||||
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
|
||||
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
|
||||
head = content[:head_chars]
|
||||
|
||||
@@ -26,6 +26,91 @@ _skill_commands_platform: Optional[str] = None
|
||||
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
||||
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill-scaffolding markers and the canonical extractor.
|
||||
#
|
||||
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
|
||||
# model-facing message that embeds the full skill body plus scaffolding. That
|
||||
# expanded text is what flows into the agent loop — and into memory providers
|
||||
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
|
||||
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
|
||||
# otherwise capture the entire skill body instead of what the user actually
|
||||
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
|
||||
# user's instruction so memory stays clean.
|
||||
#
|
||||
# These markers MUST stay byte-identical to the builders below
|
||||
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
|
||||
# agent/skill_bundles.py). They are co-located with the single-skill builder
|
||||
# on purpose, and the bundle markers are asserted against the bundle builder in
|
||||
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
|
||||
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
|
||||
_SINGLE_SKILL_INSTRUCTION = (
|
||||
"The user has provided the following instruction alongside the skill invocation: "
|
||||
)
|
||||
_RUNTIME_NOTE = "\n\n[Runtime note:"
|
||||
_BUNDLE_MARKER = " skill bundle,"
|
||||
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
|
||||
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
|
||||
|
||||
|
||||
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
|
||||
"""Recover the user's instruction from a slash-skill-expanded turn.
|
||||
|
||||
Returns:
|
||||
- The original string unchanged when it is NOT skill scaffolding
|
||||
(a normal user message passes straight through).
|
||||
- The extracted user instruction when the scaffolding carried one.
|
||||
- ``None`` when the content is skill scaffolding with no user
|
||||
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
|
||||
memory providers should skip the turn in that case — there is no
|
||||
user content worth storing.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
|
||||
if not content.startswith(_SKILL_INVOCATION_PREFIX):
|
||||
return content
|
||||
|
||||
if _BUNDLE_MARKER in content:
|
||||
return _extract_bundle_user_instruction(content)
|
||||
|
||||
if _SINGLE_SKILL_MARKER in content:
|
||||
return _extract_single_skill_user_instruction(content)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
|
||||
# Single-skill format appends the user instruction after the skill body, so
|
||||
# the last occurrence is the user-provided one; the body may quote this text.
|
||||
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
|
||||
if marker_idx < 0:
|
||||
return None
|
||||
|
||||
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
|
||||
runtime_idx = instruction.find(_RUNTIME_NOTE)
|
||||
if runtime_idx >= 0:
|
||||
instruction = instruction[:runtime_idx]
|
||||
instruction = instruction.strip()
|
||||
return instruction or None
|
||||
|
||||
|
||||
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
|
||||
# Bundle format puts the user instruction before the loaded skills, so the
|
||||
# first occurrence is the user-provided one.
|
||||
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
|
||||
if marker_idx < 0:
|
||||
return None
|
||||
|
||||
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
|
||||
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
|
||||
if first_skill_idx >= 0:
|
||||
instruction = instruction[:first_skill_idx]
|
||||
instruction = instruction.strip()
|
||||
return instruction or None
|
||||
|
||||
|
||||
def _resolve_skill_commands_platform() -> Optional[str]:
|
||||
"""Return the current platform scope used for disabled-skill filtering.
|
||||
|
||||
+53
-9
@@ -43,14 +43,20 @@ EXCLUDED_SKILL_DIRS = frozenset(
|
||||
)
|
||||
)
|
||||
|
||||
# Supporting files live inside a skill package and are loaded explicitly via
|
||||
# skill_view(skill, file_path=...). They are not standalone skills and must not
|
||||
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
|
||||
# archive workflow preserves a complete old skill package under references/.
|
||||
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
|
||||
|
||||
|
||||
def is_excluded_skill_path(path) -> bool:
|
||||
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS.
|
||||
"""True if *path* should be skipped by active skill scanners.
|
||||
|
||||
Use this on every SKILL.md path produced by ``rglob`` to prune
|
||||
dependency, virtualenv, VCS, and cache directories. Centralising the
|
||||
check here keeps every skill-scanning site in sync with the shared
|
||||
exclusion set.
|
||||
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
|
||||
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
|
||||
support-package paths. Centralising the check here keeps every
|
||||
skill-scanning site in sync with the shared exclusion set.
|
||||
|
||||
Accepts a Path or string.
|
||||
"""
|
||||
@@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool:
|
||||
except AttributeError:
|
||||
from pathlib import PurePath
|
||||
parts = PurePath(str(path)).parts
|
||||
return any(part in EXCLUDED_SKILL_DIRS for part in parts)
|
||||
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
|
||||
path
|
||||
)
|
||||
|
||||
|
||||
def is_skill_support_path(path) -> bool:
|
||||
"""True if *path* is under a support dir of an actual skill root.
|
||||
|
||||
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
|
||||
progressive-disclosure support areas when they sit directly inside a skill
|
||||
directory containing ``SKILL.md``. They are not active discovery roots for
|
||||
standalone skills. A preserved package such as
|
||||
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
|
||||
unless the caller explicitly loads it via ``file_path``.
|
||||
|
||||
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
|
||||
discoverable because their ``scripts`` component is not directly under a
|
||||
directory that contains ``SKILL.md``.
|
||||
"""
|
||||
path_obj = path if isinstance(path, Path) else Path(str(path))
|
||||
parts = path_obj.parts
|
||||
# Last component may be a file or candidate skill directory name. Only
|
||||
# components before the leaf can be containing support directories.
|
||||
for idx, part in enumerate(parts[:-1]):
|
||||
if part not in SKILL_SUPPORT_DIRS or idx == 0:
|
||||
continue
|
||||
skill_root = Path(*parts[:idx])
|
||||
if (skill_root / "SKILL.md").exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Lazy YAML loader ─────────────────────────────────────────────────────
|
||||
@@ -661,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
|
||||
def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
"""Walk skills_dir yielding sorted paths matching *filename*.
|
||||
|
||||
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache
|
||||
directories so dependencies cannot register nested skills.
|
||||
Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
|
||||
support directories. Support directories (references/templates/assets/
|
||||
scripts) can contain arbitrary markdown and even archived package
|
||||
``SKILL.md`` files, but they are progressive-disclosure data loaded through
|
||||
``skill_view(..., file_path=...)`` rather than active skill roots.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
|
||||
has_skill_md = "SKILL.md" in files
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if d not in EXCLUDED_SKILL_DIRS
|
||||
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
|
||||
]
|
||||
if filename in files:
|
||||
matches.append(Path(root) / filename)
|
||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||
|
||||
@@ -40,6 +40,7 @@ from agent.prompt_builder import (
|
||||
TASK_COMPLETION_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
drain_truncation_warnings,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
|
||||
@@ -400,7 +401,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
|
||||
warm across turns.
|
||||
"""
|
||||
parts = build_system_prompt_parts(agent, system_message=system_message)
|
||||
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
||||
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
||||
|
||||
# Surface context-file truncation warnings through the normal agent status
|
||||
# channel so gateway/CLI users see them in chat instead of only in logs.
|
||||
for warning in drain_truncation_warnings():
|
||||
agent._emit_status(warning)
|
||||
|
||||
return joined
|
||||
|
||||
|
||||
def invalidate_system_prompt(agent: Any) -> None:
|
||||
|
||||
@@ -5155,6 +5155,142 @@ function createNewSessionWindow() {
|
||||
return spawnSecondaryWindow({ newSession: true })
|
||||
}
|
||||
|
||||
// The pet overlay: a single transparent, frameless, always-on-top window that
|
||||
// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out"
|
||||
// here so it can leave the app's bounds and stay visible while Hermes is
|
||||
// minimized (Codex-style task-completion glance). It carries no gateway
|
||||
// connection of its own — the main renderer is the single source of truth and
|
||||
// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders
|
||||
// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control.
|
||||
let petOverlayWindow = null
|
||||
|
||||
function petOverlayUrl() {
|
||||
if (DEV_SERVER) {
|
||||
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/`
|
||||
}
|
||||
|
||||
function spawnPetOverlayWindow(bounds) {
|
||||
const win = new BrowserWindow({
|
||||
width: Math.max(80, Math.round(bounds?.width || 220)),
|
||||
height: Math.max(80, Math.round(bounds?.height || 220)),
|
||||
x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined,
|
||||
y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
// Windows/Linux need this so the helper window does not get its own
|
||||
// taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make
|
||||
// the whole app look like it vanished when the only newly-created visible
|
||||
// window is a frameless overlay. Use NSPanel + Mission Control hiding below
|
||||
// instead, leaving the main Hermes app as the Dock/cmd-tab anchor.
|
||||
skipTaskbar: !IS_MAC,
|
||||
hasShadow: false,
|
||||
alwaysOnTop: true,
|
||||
// macOS panels are non-activating helper windows and can float over full
|
||||
// screen spaces without becoming the app's main switcher window.
|
||||
type: IS_MAC ? 'panel' : undefined,
|
||||
hiddenInMissionControl: IS_MAC,
|
||||
// Non-activating: the overlay must never become the app's key/main window,
|
||||
// or it (a frameless, taskbar-skipping panel) becomes the app's switcher
|
||||
// anchor and the Hermes icon drops out of cmd/alt-tab — especially when the
|
||||
// main window is minimized. We flip this on only while the composer needs
|
||||
// the keyboard (see hermes:pet-overlay:set-focusable).
|
||||
focusable: false,
|
||||
show: false,
|
||||
// Fully transparent — the renderer paints only the sprite + bubble.
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true,
|
||||
// Keep the sprite animating + bubble updating while the main window is
|
||||
// minimized/blurred — the whole point of the overlay.
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
// Float above other apps and follow the user across desktops so the pet is
|
||||
// always reachable. `floating` + `type: panel` is the macOS NSPanel path; the
|
||||
// more aggressive `screen-saver` level can interfere with normal app/window
|
||||
// switching semantics.
|
||||
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
|
||||
win.setHiddenInMissionControl?.(true)
|
||||
try {
|
||||
// Electron docs: macOS may transform process type on each
|
||||
// setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true,
|
||||
// which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal
|
||||
// ForegroundApplication class so shift-clicking the pet never drops the app
|
||||
// out of app switchers.
|
||||
win.setVisibleOnAllWorkspaces(
|
||||
true,
|
||||
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
|
||||
)
|
||||
} catch {
|
||||
// Not supported everywhere — best effort.
|
||||
}
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) win.showInactive()
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
if (petOverlayWindow === win) {
|
||||
petOverlayWindow = null
|
||||
}
|
||||
|
||||
// If the overlay went away on its own (e.g. ⌘W), tell the main renderer to
|
||||
// pop the pet back in so it doesn't stay hidden. Harmless echo when we're
|
||||
// the ones who closed it (popInPet already cleared the active flag).
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' })
|
||||
}
|
||||
})
|
||||
|
||||
win.loadURL(petOverlayUrl())
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
function openPetOverlay(bounds) {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
if (bounds) {
|
||||
petOverlayWindow.setBounds({
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(80, Math.round(bounds.width)),
|
||||
height: Math.max(80, Math.round(bounds.height))
|
||||
})
|
||||
}
|
||||
|
||||
petOverlayWindow.showInactive()
|
||||
|
||||
return petOverlayWindow
|
||||
}
|
||||
|
||||
petOverlayWindow = spawnPetOverlayWindow(bounds)
|
||||
|
||||
return petOverlayWindow
|
||||
}
|
||||
|
||||
function closePetOverlay() {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.close()
|
||||
}
|
||||
|
||||
petOverlayWindow = null
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const icon = getAppIconPath()
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -5224,6 +5360,11 @@ function createWindow() {
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
// The overlay rides the main window — closing the app's primary window must
|
||||
// tear it down too (otherwise it strands as an orphan that blocks
|
||||
// window-all-closed from quitting on Windows/Linux).
|
||||
mainWindow.on('closed', () => closePetOverlay())
|
||||
|
||||
wireCommonWindowHandlers(mainWindow)
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
@@ -5344,6 +5485,116 @@ ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// --- Pet overlay (pop-out mascot) -----------------------------------------
|
||||
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
|
||||
// bounds (screen=false): convert to screen space by adding the main window's
|
||||
// content origin so the pet lands where it sat in-window. A remembered/dragged
|
||||
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
|
||||
// resolved screen bounds so the renderer can persist exactly where it opened.
|
||||
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
|
||||
const bounds = request && request.bounds ? request.bounds : request
|
||||
const isScreen = Boolean(request && request.screen)
|
||||
let screenBounds = bounds
|
||||
|
||||
try {
|
||||
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
|
||||
const content = mainWindow.getContentBounds()
|
||||
screenBounds = {
|
||||
x: content.x + (bounds.x || 0),
|
||||
y: content.y + (bounds.y || 0),
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to raw bounds if the window geometry is unavailable.
|
||||
}
|
||||
|
||||
openPetOverlay(screenBounds)
|
||||
|
||||
return { ok: true, bounds: screenBounds }
|
||||
})
|
||||
ipcMain.handle('hermes:pet-overlay:close', async () => {
|
||||
closePetOverlay()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
// Drag: the overlay reports a new absolute screen position (it already knows the
|
||||
// pointer's screen coords), we just move the window.
|
||||
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setBounds({
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(80, Math.round(bounds.width)),
|
||||
height: Math.max(80, Math.round(bounds.height))
|
||||
})
|
||||
})
|
||||
// Click-through: the overlay window is a full rectangle but only the pet pixels
|
||||
// should be interactive. The renderer toggles this as the cursor enters/leaves
|
||||
// the sprite so transparent margins pass clicks to whatever is behind.
|
||||
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
|
||||
}
|
||||
})
|
||||
// The overlay is a non-activating panel (focusable:false) so it never steals
|
||||
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
|
||||
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
|
||||
// while the composer is open, then back to non-activating when it closes.
|
||||
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setFocusable(Boolean(focusable))
|
||||
if (focusable) {
|
||||
petOverlayWindow.focus()
|
||||
}
|
||||
})
|
||||
// Main renderer → overlay: forward the latest pet state for the overlay to render.
|
||||
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
|
||||
}
|
||||
})
|
||||
// Overlay → main renderer: control messages (pop back in, composer submit).
|
||||
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window: hide it away if it's up front, bring it
|
||||
// back if it's minimized/buried. Pure window control — nothing for the
|
||||
// renderer to do, so don't forward it.
|
||||
if (payload && payload.type === 'toggle-app') {
|
||||
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
mainWindow.minimize()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The mail icon means "take me to the app": raise the main window (it may be
|
||||
// minimized or buried) before the renderer navigates to the latest thread.
|
||||
if (payload && payload.type === 'open-app') {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
@@ -6548,6 +6799,10 @@ function configureSpellChecker() {
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// The always-on-top overlay isn't a "real" app window; close it so a stray
|
||||
// pet can't keep the process alive or float over a quit app.
|
||||
closePetOverlay()
|
||||
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,32 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request),
|
||||
close: () => ipcRenderer.invoke('hermes:pet-overlay:close'),
|
||||
setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds),
|
||||
setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore),
|
||||
// Flip the overlay focusable (and focus it) while the composer needs keys.
|
||||
setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable),
|
||||
// Main renderer → overlay (forwarded by main): push the latest pet state.
|
||||
pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload),
|
||||
// Overlay → main renderer (forwarded by main): pop back in / composer submit.
|
||||
control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload),
|
||||
// Overlay subscribes to state pushes.
|
||||
onState: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:state', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
|
||||
},
|
||||
// Main renderer subscribes to overlay control messages.
|
||||
onControl: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:control', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import { ModelPill } from './model-pill'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
||||
@@ -66,6 +67,7 @@ export function ComposerControls({
|
||||
const c = t.composer
|
||||
const steerCombo = formatCombo('mod+enter')
|
||||
const steerLabel = `${c.steer} (${steerCombo})`
|
||||
|
||||
const steerTip = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
@@ -81,8 +83,10 @@ export function ComposerControls({
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<ModelPill disabled={disabled} model={state.model} />
|
||||
{/* While the agent runs and the user is typing, steer takes over the mic's
|
||||
slot rather than crowding the row with an extra button. */}
|
||||
{canSteer ? (
|
||||
<Tip label={steerTip}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
@@ -96,6 +100,8 @@ export function ComposerControls({
|
||||
<SteeringWheel size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PILL = cn(
|
||||
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
|
||||
/**
|
||||
* Composer model selector — the relocated status-bar pill. Reuses the live
|
||||
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
|
||||
* full picker when the gateway is closed and no live menu exists.
|
||||
*/
|
||||
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
|
||||
const copy = useI18n().t.shell.statusbar
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const fastMode = useStore($currentFastMode)
|
||||
const reasoningEffort = useStore($currentReasoningEffort)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// The model resolves a beat after the gateway/session comes up. Rather than
|
||||
// flash a literal "No model", show a quiet loader (inherits the pill text
|
||||
// color at half opacity) until a model lands.
|
||||
const label = (
|
||||
<>
|
||||
{currentModel.trim() ? (
|
||||
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
|
||||
) : (
|
||||
<GlyphSpinner className="opacity-50" spinner="braille" />
|
||||
)}
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</>
|
||||
)
|
||||
|
||||
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
|
||||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={PILL}
|
||||
disabled={disabled}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title={copy.openModelPicker}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||
{model.modelMenuContent}
|
||||
</ModelMenuCloseContext.Provider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
@@ -22,6 +24,8 @@ export interface ChatBarState {
|
||||
canSwitch: boolean
|
||||
loading?: boolean
|
||||
quickModels?: QuickModelOption[]
|
||||
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
|
||||
modelMenuContent?: ReactNode
|
||||
}
|
||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||
voice: { enabled: boolean; active: boolean }
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
@@ -62,6 +62,7 @@ import { threadLoadingState } from './thread-loading'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
modelMenuContent?: React.ReactNode
|
||||
onToggleSelectedPin: () => void
|
||||
onDeleteSelectedSession: () => void
|
||||
onCancel: () => Promise<void> | void
|
||||
@@ -120,10 +121,10 @@ function ChatHeader({
|
||||
? pinnedSessionIds.includes(selectedSessionId)
|
||||
: false
|
||||
|
||||
// A brand-new session has no session to pin/delete/rename, so the header is
|
||||
// just a dead "New session" label + chevron. Drop it (and its border)
|
||||
// entirely until there's a real session to act on.
|
||||
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// are compact side panels — they drop the session-actions header + border
|
||||
// entirely. A brand-new draft has nothing to pin/delete/rename either.
|
||||
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -250,6 +251,7 @@ function ChatRuntimeBoundary({
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
modelMenuContent,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
onCancel,
|
||||
@@ -346,6 +348,7 @@ export function ChatView({
|
||||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
modelMenuContent,
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
@@ -358,7 +361,7 @@ export function ChatView({
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
|
||||
)
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Moon,
|
||||
Package,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Plus,
|
||||
Settings,
|
||||
Settings2,
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
@@ -62,6 +63,7 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
|
||||
|
||||
interface PaletteItem {
|
||||
/** Keybind action id — its live combo renders as a hotkey hint. */
|
||||
@@ -205,6 +207,7 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const pendingPage = useStore($commandPalettePage)
|
||||
const bindings = useStore($bindings)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
@@ -250,6 +253,14 @@ export function CommandPalette() {
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
|
||||
useEffect(() => {
|
||||
if (open && pendingPage) {
|
||||
setPage(pendingPage)
|
||||
$commandPalettePage.set(null)
|
||||
}
|
||||
}, [open, pendingPage])
|
||||
|
||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||
|
||||
// Step up one nested page (or back to the root list), clearing the filter so
|
||||
@@ -382,6 +393,13 @@ export function CommandPalette() {
|
||||
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
|
||||
label: cc.changeColorMode,
|
||||
to: 'color-mode'
|
||||
},
|
||||
{
|
||||
icon: PawPrint,
|
||||
id: 'appearance-pets',
|
||||
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
|
||||
label: cc.pets.title,
|
||||
to: 'pets'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -550,6 +568,12 @@ export function CommandPalette() {
|
||||
}
|
||||
]
|
||||
},
|
||||
// Server-driven page: browse petdex gallery, adopt/switch, toggle off.
|
||||
pets: {
|
||||
title: t.commandCenter.pets.title,
|
||||
placeholder: t.commandCenter.pets.placeholder,
|
||||
groups: []
|
||||
},
|
||||
// Server-driven page: items come from the Marketplace, rendered by
|
||||
// <MarketplaceThemePage> (loader + live search + per-row install).
|
||||
'install-theme': {
|
||||
@@ -624,45 +648,51 @@ export function CommandPalette() {
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
right={page === 'pets' ? <PetInlineToggle /> : undefined}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{page === 'install-theme' ? (
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage search={search} />
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
)}
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
<>
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Cmd-K "Pets…" page — browse the petdex gallery, adopt/switch, toggle off.
|
||||
*
|
||||
* A thin view over the `pet-gallery` store: it subscribes to the shared atoms
|
||||
* and calls the store's actions. The store owns fetching, caching, the thumb
|
||||
* cache, and optimistic mutations, so reopening this page is instant and a
|
||||
* toggle never re-pulls the network gallery.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Loader2, PawPrint } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petBusy,
|
||||
$petGallery,
|
||||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
rankedGalleryPets,
|
||||
setPetEnabled
|
||||
} from '@/store/pet-gallery'
|
||||
|
||||
interface PetPalettePageProps {
|
||||
search: string
|
||||
}
|
||||
|
||||
export function PetPalettePage({ search }: PetPalettePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const gallery = useStore($petGallery)
|
||||
const status = useStore($petGalleryStatus)
|
||||
const error = useStore($petGalleryError)
|
||||
const busy = useStore($petBusy)
|
||||
|
||||
useEffect(() => {
|
||||
void loadPetGallery(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
|
||||
const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search])
|
||||
|
||||
const adopt = (slug: string) => {
|
||||
void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
if (status === 'loading' && !gallery) {
|
||||
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
}
|
||||
|
||||
if (status === 'stale') {
|
||||
return <Status text={copy.staleBackend} tone="error" />
|
||||
}
|
||||
|
||||
if (!gallery?.pets.length && error) {
|
||||
return <Status text={error} tone="error" />
|
||||
}
|
||||
|
||||
const mutating = Boolean(busy)
|
||||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<Status text={copy.empty} />
|
||||
) : (
|
||||
shown.map(pet => {
|
||||
const isActive = enabled && pet.slug === active
|
||||
const isBusy = busy === pet.slug
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT,
|
||||
isActive && 'bg-(--chrome-action-hover)/70'
|
||||
)}
|
||||
disabled={mutating && !isBusy}
|
||||
key={pet.slug}
|
||||
onClick={() => adopt(pet.slug)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<PetThumb
|
||||
alt={pet.displayName}
|
||||
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
|
||||
size={32}
|
||||
slug={pet.slug}
|
||||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{pet.displayName}</span>
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||
{pet.slug}
|
||||
{pet.installed ? ` · ${copy.installed}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center text-[0.6875rem] text-muted-foreground">
|
||||
{isBusy ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isActive ? (
|
||||
<Check className="size-3.5 text-foreground" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single on/off toggle, rendered inline on the palette's search row (see
|
||||
* `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the
|
||||
* same shared gallery atoms, so it stays in sync with the list below.
|
||||
*/
|
||||
export function PetInlineToggle() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const gallery = useStore($petGallery)
|
||||
const busy = useStore($petBusy)
|
||||
|
||||
if (!gallery) {
|
||||
return null
|
||||
}
|
||||
|
||||
const enabled = gallery.enabled
|
||||
|
||||
const toggle = () => {
|
||||
void setPetEnabled(requestGateway, !enabled, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
fallback: copy.toggleFailed
|
||||
}).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={enabled ? copy.turnOff : copy.turnOn}
|
||||
aria-pressed={enabled}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
|
||||
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
|
||||
)}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={toggle}
|
||||
// Don't steal focus from the search input on click.
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
title={enabled ? copy.turnOff : copy.turnOn}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
|
||||
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { setPetActivity } from '../store/pet'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
} from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
@@ -711,7 +714,9 @@ export function DesktopController() {
|
||||
}
|
||||
|
||||
lastGatewayProfileRef.current = activeGatewayProfile
|
||||
void refreshCurrentModel()
|
||||
// Force: the new profile has its own default, so reseed even if the composer
|
||||
// already shows the previous profile's model.
|
||||
void refreshCurrentModel(true)
|
||||
void refreshActiveProfile()
|
||||
}, [activeGatewayProfile, refreshCurrentModel])
|
||||
|
||||
@@ -784,6 +789,53 @@ export function DesktopController() {
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// The popped-out pet drives two actions back into the app: send a prompt, and
|
||||
// open the most recent thread. Both are registered ONCE through refs that track
|
||||
// the latest callbacks — re-registering on every `submitText`/`resumeSession`
|
||||
// identity change left a brief window where the handler was nulled (cleanup
|
||||
// before re-register), which could drop a submit fired from the overlay (e.g.
|
||||
// creating a session from the new-session screen). The ref form keeps a stable,
|
||||
// always-current handler. Primary window only — it owns the overlay.
|
||||
const submitTextRef = useRef(submitText)
|
||||
submitTextRef.current = submitText
|
||||
const resumeSessionRef = useRef(resumeSession)
|
||||
resumeSessionRef.current = resumeSession
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
|
||||
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
|
||||
// per session) so "most recent" is the right target. main.cjs already raised
|
||||
// the window before forwarding this.
|
||||
setPetOverlayOpenAppHandler(() => {
|
||||
const recent = $sessions.get()[0]
|
||||
|
||||
if (recent?.id) {
|
||||
void resumeSessionRef.current(recent.id)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
setPetOverlaySubmitHandler(null)
|
||||
setPetOverlayOpenAppHandler(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
|
||||
// awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so
|
||||
// it rides the same atom the pop-out overlay mirrors — no session list needed
|
||||
// there. Every window keeps its own in-window pet in sync.
|
||||
useEffect(() => {
|
||||
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
|
||||
|
||||
sync()
|
||||
|
||||
return $attentionSessionIds.listen(sync)
|
||||
}, [])
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
onConnectionReady: c => {
|
||||
@@ -859,7 +911,6 @@ export function DesktopController() {
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
freshDraftReady,
|
||||
openCommandCenterSection,
|
||||
@@ -981,6 +1032,7 @@ export function DesktopController() {
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { ThemeProvider } from '@/themes/context'
|
||||
|
||||
import { PetOverlayApp } from './pet-overlay-app'
|
||||
|
||||
/**
|
||||
* Boot the pet-overlay window. Loaded by the same bundle as the main app but
|
||||
* via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent
|
||||
* surface (no app shell, no gateway, no I18n — the bubble strings are inline).
|
||||
*
|
||||
* The index.html boot script paints an OPAQUE themed background to avoid a flash
|
||||
* in normal windows; the overlay must be see-through, so we force every host
|
||||
* layer transparent with a late, high-specificity style tag.
|
||||
*/
|
||||
export function mountPetOverlay(): void {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'html,body,#root{background:transparent !important;}'
|
||||
document.head.appendChild(style)
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="pet-overlay">
|
||||
<ThemeProvider>
|
||||
<PetOverlayApp />
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { Mail } from '@/lib/icons'
|
||||
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
||||
import { setAwaitingResponse, setBusy } from '@/store/session'
|
||||
|
||||
/**
|
||||
* The pop-out overlay's only view: a transparent, draggable mascot with a mini
|
||||
* composer.
|
||||
*
|
||||
* This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a
|
||||
* pure puppet — the main renderer pushes the live pet state over IPC and we
|
||||
* mirror it into the same atoms the in-window pet reads, so `PetSprite` /
|
||||
* `PetBubble` render identically with zero extra logic.
|
||||
*
|
||||
* The window is a full rectangle but mostly transparent; we toggle OS-level
|
||||
* mouse click-through so only the sprite (or the open composer) is interactive
|
||||
* and the empty margins pass clicks through to whatever is behind.
|
||||
*
|
||||
* Gestures on the pet: drag to move it anywhere on screen (even outside the
|
||||
* app), shift-click to pop it back into the window, single-click to open a small
|
||||
* composer, double-click to toggle the app window (minimize ↔ restore). A mail
|
||||
* icon (shown only when a turn finished while you were away) raises the app on
|
||||
* the most recent thread.
|
||||
*/
|
||||
|
||||
// Below this much pointer travel, a press counts as a click, not a drag.
|
||||
const CLICK_SLOP_PX = 3
|
||||
// A second click within this window is a double-click (raise app) and cancels
|
||||
// the deferred single-click (open composer), so a double never flashes it open.
|
||||
const DOUBLE_CLICK_MS = 250
|
||||
|
||||
interface DragState {
|
||||
startX: number
|
||||
startY: number
|
||||
offX: number
|
||||
offY: number
|
||||
width: number
|
||||
height: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export function PetOverlayApp() {
|
||||
const info = useStore($petInfo)
|
||||
const [composerOpen, setComposerOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
// Mirrored from the main renderer: a finish landed while you were away.
|
||||
const [unread, setUnread] = useState(false)
|
||||
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
const petRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const ignoreRef = useRef(true)
|
||||
const composerOpenRef = useRef(false)
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const setIgnore = (ignore: boolean) => {
|
||||
if (ignoreRef.current !== ignore) {
|
||||
ignoreRef.current = ignore
|
||||
window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror pushed state into the shared atoms so PetSprite/PetBubble just work.
|
||||
useEffect(() => {
|
||||
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
|
||||
setPetInfo(payload.info)
|
||||
$petActivity.set(payload.activity ?? {})
|
||||
setBusy(Boolean(payload.busy))
|
||||
setAwaitingResponse(Boolean(payload.awaiting))
|
||||
setUnread(Boolean(payload.unread))
|
||||
})
|
||||
|
||||
// Tell the main renderer we're mounted so it pushes the current frame (the
|
||||
// subscribe-time pushes during open() can land before this view exists).
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
|
||||
|
||||
return off
|
||||
}, [])
|
||||
|
||||
// Click-through: make only the sprite (or an open composer) interactive. With
|
||||
// ignore+forward, the renderer still receives mousemove so we can re-enable
|
||||
// hit-testing the moment the cursor returns to the pet.
|
||||
useEffect(() => {
|
||||
setIgnore(true)
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (dragRef.current || composerOpenRef.current) {
|
||||
setIgnore(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const el = petRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect()
|
||||
const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
|
||||
setIgnore(!over)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
clearTimeout(clickTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// The whole window must stay interactive while the composer is open (so the
|
||||
// input keeps focus); focus it on open. The overlay is a non-activating panel
|
||||
// (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while
|
||||
// the composer needs the keyboard, then back to non-activating when it closes.
|
||||
useEffect(() => {
|
||||
composerOpenRef.current = composerOpen
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setFocusable(composerOpen)
|
||||
|
||||
if (composerOpen) {
|
||||
setIgnore(false)
|
||||
// The OS window has to become key first (setFocusable + focus happen in
|
||||
// the main process), so focus the input on the next frame.
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [composerOpen])
|
||||
|
||||
const onPetPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||
dragRef.current = {
|
||||
height: window.outerHeight,
|
||||
moved: false,
|
||||
offX: e.screenX - window.screenX,
|
||||
offY: e.screenY - window.screenY,
|
||||
startX: e.screenX,
|
||||
startY: e.screenY,
|
||||
width: window.outerWidth
|
||||
}
|
||||
}
|
||||
|
||||
const onPetPointerMove = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) {
|
||||
drag.moved = true
|
||||
}
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setBounds({
|
||||
height: drag.height,
|
||||
width: drag.width,
|
||||
x: e.screenX - drag.offX,
|
||||
y: e.screenY - drag.offY
|
||||
})
|
||||
}
|
||||
|
||||
const onPetPointerUp = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
dragRef.current = null
|
||||
;(e.target as Element).releasePointerCapture?.(e.pointerId)
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (drag.moved) {
|
||||
// A drag cancels any deferred single-click so the composer can't pop open
|
||||
// after you reposition the pet.
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
|
||||
// Remember the spot on the desktop (screen coords) so the pet reopens here
|
||||
// next time / after a restart.
|
||||
window.hermesDesktop?.petOverlay?.control({
|
||||
bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY },
|
||||
type: 'bounds'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Shift-click always pops the pet back in (no double-click ambiguity).
|
||||
if (e.shiftKey) {
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window (minimize ↔ restore); defer the
|
||||
// single-click composer toggle so a double never flashes the composer open.
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = undefined
|
||||
setComposerOpen(open => !open)
|
||||
}, DOUBLE_CLICK_MS)
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const text = draft.trim()
|
||||
|
||||
if (text) {
|
||||
window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' })
|
||||
}
|
||||
|
||||
setDraft('')
|
||||
setComposerOpen(false)
|
||||
}
|
||||
|
||||
const openApp = () => {
|
||||
// Hide the icon immediately; the main renderer also clears the source flag.
|
||||
setUnread(false)
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'open-app' })
|
||||
}
|
||||
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={e => {
|
||||
// Click on the transparent backdrop (not the pet/composer) dismisses
|
||||
// the composer.
|
||||
if (composerOpen && e.target === e.currentTarget) {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 24,
|
||||
userSelect: 'none',
|
||||
width: '100vw'
|
||||
}}
|
||||
>
|
||||
{composerOpen && (
|
||||
<input
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
} else if (e.key === 'Escape') {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Message…"
|
||||
ref={inputRef}
|
||||
style={{
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 12,
|
||||
marginBottom: 8,
|
||||
outline: 'none',
|
||||
padding: '4px 8px',
|
||||
width: 184
|
||||
}}
|
||||
value={draft}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
onPointerDown={onPetPointerDown}
|
||||
onPointerMove={onPetPointerMove}
|
||||
onPointerUp={onPetPointerUp}
|
||||
ref={petRef}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<PetBubble />
|
||||
</div>
|
||||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
|
||||
{/* Mail icon: only when a finish landed while you were away. Jumps to
|
||||
the app's most recent thread. Anchored to the sprite (kept inside
|
||||
its box so the overlay's click-through hit-test still catches it);
|
||||
stopPropagation keeps a click from starting a window drag. */}
|
||||
{unread && (
|
||||
<button
|
||||
aria-label="Open in Hermes"
|
||||
onClick={openApp}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onPointerUp={e => e.stopPropagation()}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 999,
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 24
|
||||
}}
|
||||
title="Open in Hermes"
|
||||
type="button"
|
||||
>
|
||||
<Mail style={{ height: 13, width: 13 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||
|
||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||
|
||||
/** A command queued to run in the embedded terminal. The terminal pane flushes
|
||||
* (and clears) it once its session is live, so a value set before the pane
|
||||
* mounts still runs. Cleared after flush so a later remount can't replay it. */
|
||||
export const $terminalInjection = atom<null | string>(null)
|
||||
|
||||
/** Open the terminal pane and run a command in it. Used to disconnect external
|
||||
* (CLI-managed) providers, which Hermes can't clear via the API — the user
|
||||
* sees exactly what runs instead of Hermes silently deleting their creds. */
|
||||
export const runInTerminal = (command: string) => {
|
||||
const trimmed = command.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setTerminalTakeover(true)
|
||||
$terminalInjection.set(trimmed)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { $terminalInjection } from '../store'
|
||||
|
||||
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||
import {
|
||||
isAddSelectionShortcut,
|
||||
@@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [activeTheme, themeName])
|
||||
|
||||
// Flush a queued command (e.g. a provider-disconnect) into the live session.
|
||||
// Only active while open; the subscribe fires immediately, so a command set
|
||||
// before this pane mounted runs as soon as the session is ready. Clearing the
|
||||
// atom after writing stops a later remount from replaying a stale command.
|
||||
useEffect(() => {
|
||||
if (status !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
return $terminalInjection.subscribe(command => {
|
||||
const id = sessionIdRef.current
|
||||
|
||||
if (!command || !id) {
|
||||
return
|
||||
}
|
||||
|
||||
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
|
||||
$terminalInjection.set(null)
|
||||
termRef.current?.focus()
|
||||
})
|
||||
}, [status])
|
||||
|
||||
return {
|
||||
addSelectionToChat,
|
||||
hostRef,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
setCurrentBranch,
|
||||
@@ -869,10 +870,18 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
} else if (event.type === 'reasoning.available') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
} else if (event.type === 'message.complete') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
@@ -894,6 +903,20 @@ export function useMessageStream({
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
|
||||
// Pet beat: a finished turn always celebrates — go straight to the
|
||||
// jump, never linger on the run/reason pose. One atom update (clears
|
||||
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
|
||||
// frame leaks to the sprite — including the popped-out overlay, which
|
||||
// mirrors each activity change. The jump runs ~2 loops, then settles.
|
||||
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)
|
||||
|
||||
// Light up the pet's mail icon if the user wasn't looking when the turn
|
||||
// finished — a glanceable "new message" hint on the popped-out overlay.
|
||||
// Cleared when they open the app via the mail icon or refocus the window.
|
||||
if (typeof document !== 'undefined' && !document.hasFocus()) {
|
||||
markPetUnread()
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.usage) {
|
||||
@@ -906,10 +929,19 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: true })
|
||||
}
|
||||
} else if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ toolRunning: false })
|
||||
}
|
||||
|
||||
// A pending clarify blocks the turn, so the first tool.complete after
|
||||
// one is the clarify resolving — drop the "needs input" flag here so
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
@@ -1093,6 +1125,11 @@ export function useMessageStream({
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: false })
|
||||
flashPetActivity({ error: true })
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
|
||||
@@ -130,7 +130,6 @@ describe('useModelControls', () => {
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'claude-sonnet-4.6',
|
||||
persistGlobal: false,
|
||||
provider: 'anthropic'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
@@ -143,26 +142,57 @@ describe('useModelControls', () => {
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps the global path on setGlobalModel when there is no active session', async () => {
|
||||
setGlobalModel.mockResolvedValue(undefined)
|
||||
it('stores a no-session pick as UI state with no gateway or global write', async () => {
|
||||
const requestGateway = vi.fn()
|
||||
let controls!: Controls
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
onReady={value => (controls = value)}
|
||||
requestGateway={vi.fn()}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'claude-sonnet-4.6',
|
||||
persistGlobal: false,
|
||||
provider: 'anthropic'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
|
||||
// The pick is plain UI state; session.create ships it later. Nothing touches
|
||||
// the gateway or the profile default here.
|
||||
expect($currentModel.get()).toBe('claude-sonnet-4.6')
|
||||
expect($currentProvider.get()).toBe('anthropic')
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
expect(setGlobalModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds an empty composer model from global but never clobbers a pick', async () => {
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelControls({
|
||||
activeSessionId: null,
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
// Empty → seeds the default.
|
||||
await result.current.refreshCurrentModel()
|
||||
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||
|
||||
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
|
||||
// draft / session events.
|
||||
setCurrentModel('anthropic/claude-sonnet-4.6')
|
||||
setCurrentProvider('anthropic')
|
||||
await result.current.refreshCurrentModel()
|
||||
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
|
||||
|
||||
// A profile swap forces a reseed to the new profile's default.
|
||||
await result.current.refreshCurrentModel(true)
|
||||
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
model: string
|
||||
persistGlobal: boolean
|
||||
provider: string
|
||||
}
|
||||
|
||||
@@ -28,6 +27,7 @@ interface ModelControlsOptions {
|
||||
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
|
||||
const updateModelOptionsCache = useCallback(
|
||||
(provider: string, model: string, includeGlobal: boolean) => {
|
||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||
@@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
[activeSessionId, queryClient]
|
||||
)
|
||||
|
||||
const refreshCurrentModel = useCallback(async () => {
|
||||
// Seed the composer's model state from the profile default. `force` reseeds
|
||||
// for a profile swap (the new profile has its own default); otherwise this
|
||||
// only fills an EMPTY selection so a user's pick (plain UI state in
|
||||
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
|
||||
// draft / session events. A live session owns the footer, so skip entirely.
|
||||
const refreshCurrentModel = useCallback(async (force = false) => {
|
||||
try {
|
||||
if ($activeSessionId.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && $currentModel.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
// A resumed/live session owns the footer model state. Global config
|
||||
// refreshes (gateway boot, profile swap, settings save) must not clobber
|
||||
// the active chat's runtime model/provider in the status bar.
|
||||
if ($activeSessionId.get()) {
|
||||
if ($activeSessionId.get() || (!force && $currentModel.get())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Returns whether the switch succeeded so callers can await it before
|
||||
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
|
||||
// on the right active model — bail rather than write to the previous one).
|
||||
// Returns whether the switch succeeded so callers can await it before applying
|
||||
// follow-up changes. The composer model is plain UI state: with no live
|
||||
// session it's just stored (and shipped on the next session.create); with one
|
||||
// it's scoped to that session via config.set. It NEVER writes the profile
|
||||
// default — that lives in Settings → Model — so picking a model here can't
|
||||
// silently mutate global config.
|
||||
const selectModel = useCallback(
|
||||
async (selection: ModelSelection): Promise<boolean> => {
|
||||
const includeGlobal = selection.persistGlobal || !activeSessionId
|
||||
// Snapshot for rollback: the switch is applied optimistically, so a
|
||||
// failure must restore the prior model/provider (store + query cache)
|
||||
// rather than leave the UI showing a model the backend never selected.
|
||||
@@ -78,42 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
|
||||
updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
|
||||
|
||||
// No live session yet: the pick is pure UI state. session.create reads
|
||||
// $currentModel/$currentProvider and applies it as that session's override.
|
||||
if (!activeSessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('config.set', {
|
||||
session_id: activeSessionId,
|
||||
key: 'model',
|
||||
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
await requestGateway('config.set', {
|
||||
session_id: activeSessionId,
|
||||
key: 'model',
|
||||
value: `${selection.model} --provider ${selection.provider}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
setCurrentModel(prevModel)
|
||||
setCurrentProvider(prevProvider)
|
||||
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
||||
updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
|
||||
notifyError(err, copy.modelSwitchFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
[activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
||||
|
||||
@@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
@@ -57,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
BrowserManageResponse,
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
@@ -1143,6 +1145,35 @@ export function usePromptActions({
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
pet: async ctx => {
|
||||
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
|
||||
const lower = sub.toLowerCase()
|
||||
|
||||
if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') {
|
||||
openCommandPalettePage('pets')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// `/pet scale <n>` resizes the floating pet locally (instant) and
|
||||
// persists via the store — no round-trip to the slash worker.
|
||||
if (lower === 'scale') {
|
||||
const value = Number(rawValue)
|
||||
|
||||
if (!rawValue || Number.isNaN(value)) {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
resolved?.render('usage: /pet scale <factor> (e.g. /pet scale 0.5)')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setPetScale(requestGateway, value)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await runExec(ctx)
|
||||
},
|
||||
// /browser connect|disconnect|status manages the live CDP connection on
|
||||
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
|
||||
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
|
||||
@@ -1359,6 +1390,7 @@ export function usePromptActions({
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
|
||||
@@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
@@ -407,13 +411,13 @@ export function useSessionActions({
|
||||
})
|
||||
setSessionStartedAt(null)
|
||||
setTurnStartedAt(null)
|
||||
// New chats start in the configured default project dir when set,
|
||||
// otherwise the sticky last-used workspace (PR #37586).
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
// The composer's model/effort/fast is sticky UI state (persisted in
|
||||
// localStorage) — a new chat FOLLOWS your last pick instead of snapping
|
||||
// back to the profile default, so we deliberately don't reset it here. The
|
||||
// profile default still owns first-run seeding and profile switches (see
|
||||
// refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
|
||||
// is cleared.
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
setCurrentBranch('')
|
||||
@@ -443,11 +447,23 @@ export function useSessionActions({
|
||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(newChatProfile)
|
||||
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||
// The composer's model/effort/fast is sticky UI state ($currentModel,
|
||||
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
|
||||
// with every session.create so the new chat opens on whatever the picker
|
||||
// shows — applied as per-session overrides, never written to the profile
|
||||
// default (that lives in Settings → Model).
|
||||
const uiModel = $currentModel.get().trim()
|
||||
const uiProvider = $currentProvider.get().trim()
|
||||
const uiEffort = $currentReasoningEffort.get().trim()
|
||||
const uiFast = $currentFastMode.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
||||
...(newChatProfile ? { profile: newChatProfile } : {}),
|
||||
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
|
||||
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
|
||||
...(uiFast ? { fast: true } : {})
|
||||
})
|
||||
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import type { DesktopMarketplaceSearchItem } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { PetSettings } from './pet-settings'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = resolveTheme(name)
|
||||
|
||||
if (!t) {
|
||||
return null
|
||||
}
|
||||
|
||||
const c = t.colors
|
||||
function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) {
|
||||
// Preview in the *current* mode: the dark palette in Dark, and the light
|
||||
// palette in Light — synthesizing one for dark-only themes — so every card
|
||||
// tracks the Light/Dark toggle, exactly like the app itself does.
|
||||
const c = getBaseColors(name, mode)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -57,90 +58,200 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function VscodeThemeInstaller() {
|
||||
function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delayMs)
|
||||
|
||||
return () => clearTimeout(handle)
|
||||
}, [value, delayMs])
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
/**
|
||||
* Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install
|
||||
* theme…" page). Renders below the local grid when there's a query: each row
|
||||
* downloads + converts + installs via `installVscodeThemeFromMarketplace` and
|
||||
* activates it. Extensions already imported locally are marked installed.
|
||||
*/
|
||||
function MarketplaceThemeResults({
|
||||
query,
|
||||
installedExtIds,
|
||||
onInstalled
|
||||
}: {
|
||||
query: string
|
||||
installedExtIds: Set<string>
|
||||
onInstalled: (name: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { setTheme } = useTheme()
|
||||
const a = t.settings.appearance
|
||||
const [id, setId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debounced = useDebounced(query.trim(), 300)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const install = async () => {
|
||||
const trimmed = id.trim()
|
||||
const search = useQuery({
|
||||
enabled: debounced.length > 0,
|
||||
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]),
|
||||
queryKey: ['marketplace-themes-settings', debounced],
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
if (!trimmed || busy) {
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setStatus(null)
|
||||
setInstallingId(item.extensionId)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const theme = await installVscodeThemeFromMarketplace(trimmed)
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
setStatus({ kind: 'success', text: a.installed(theme.label) })
|
||||
setId('')
|
||||
} catch (error) {
|
||||
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
|
||||
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onInstalled(theme.name)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : copy.error)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setInstallingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
disabled={busy}
|
||||
onChange={event => {
|
||||
setId(event.target.value)
|
||||
setStatus(null)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void install()
|
||||
}
|
||||
}}
|
||||
placeholder={a.installPlaceholder}
|
||||
spellCheck={false}
|
||||
value={id}
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||
disabled={busy || !id.trim()}
|
||||
onClick={() => void install()}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
{busy ? a.installing : a.installButton}
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
||||
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{status.text}
|
||||
if (!debounced) {
|
||||
return null
|
||||
}
|
||||
|
||||
const header = (
|
||||
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
From the VS Code Marketplace
|
||||
</p>
|
||||
)
|
||||
|
||||
if (search.isLoading) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="flex items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loading}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (search.isError) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{copy.error}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const results = search.data ?? []
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{copy.empty}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{error && <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{error}</p>}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-60',
|
||||
selectableCardClass({ prominent: done })
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => void install(item)}
|
||||
type="button"
|
||||
>
|
||||
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{item.displayName}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{item.publisher}
|
||||
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-(--ui-text-tertiary)">
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : done ? (
|
||||
<Check className="size-4 text-(--ui-green)" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const translucency = useStore($translucency)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// One box does double duty: filter installed themes live (below), and run a
|
||||
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
|
||||
// backend) for anything not already installed.
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
const filteredThemes = availableThemes
|
||||
.filter(
|
||||
theme =>
|
||||
!needle ||
|
||||
theme.label.toLowerCase().includes(needle) ||
|
||||
theme.name.toLowerCase().includes(needle) ||
|
||||
theme.description.toLowerCase().includes(needle)
|
||||
)
|
||||
// Active theme first; stable sort keeps the rest in their original order.
|
||||
.sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName))
|
||||
|
||||
// Marketplace imports describe themselves as "VS Code · <publisher.extension>";
|
||||
// pull those ids back out so search results already imported show as installed.
|
||||
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
|
||||
|
||||
const installedExtIds = new Set(
|
||||
availableThemes
|
||||
.map(theme =>
|
||||
theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
|
||||
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// Themes save per profile. Surface that only when the user actually has more
|
||||
// than one profile (single-profile installs never see the distinction).
|
||||
const showProfileNote = profiles.length > 1
|
||||
@@ -163,7 +274,7 @@ export function AppearanceSettings() {
|
||||
{a.intro}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
|
||||
<div className="mt-2">
|
||||
<ListRow
|
||||
action={<LanguageSwitcher />}
|
||||
description={isSavingLocale ? t.language.saving : t.language.description}
|
||||
@@ -171,18 +282,107 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
below={
|
||||
<>
|
||||
{/* One search box: filters your installed themes (the grid)
|
||||
and live-searches the VS Code Marketplace below. */}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search your themes or the VS Code Marketplace…"
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fixed-height scroll area so the (growing) theme list never
|
||||
runs the page long; the grid scrolls inside it. */}
|
||||
<div className="mt-3 max-h-96 overflow-y-auto pr-1">
|
||||
{filteredThemes.length === 0 ? (
|
||||
needle ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No installed themes match "{query.trim()}".
|
||||
</p>
|
||||
) : null
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn('w-full p-2 text-left', selectableCardClass({ active, prominent: true }))}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview mode={resolvedMode} name={theme.name} />
|
||||
<div className="mt-3 px-1">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<MarketplaceThemeResults
|
||||
installedExtIds={installedExtIds}
|
||||
onInstalled={name => setTheme(name)}
|
||||
query={query}
|
||||
/>
|
||||
</div>
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.colorModeDesc}
|
||||
title={a.colorMode}
|
||||
description={a.themeDesc}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{a.themeTitle}</span>
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
@@ -211,80 +411,6 @@ export function AppearanceSettings() {
|
||||
title={a.translucencyTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<VscodeThemeInstaller />
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
@@ -301,6 +427,10 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<PetSettings />
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
/>
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $petInfo } from '@/store/pet'
|
||||
import {
|
||||
$petBusy,
|
||||
$petGallery,
|
||||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
PET_SCALE_DEFAULT,
|
||||
PET_SCALE_MAX,
|
||||
PET_SCALE_MIN,
|
||||
rankedGalleryPets,
|
||||
removePet as removePetAction,
|
||||
setPetEnabled,
|
||||
setPetScale
|
||||
} from '@/store/pet-gallery'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { ListRow, SectionHeading } from './primitives'
|
||||
|
||||
/**
|
||||
* Appearance opt-in for the floating petdex mascot. A thin view over the shared
|
||||
* `pet-gallery` store — it subscribes to the atoms and calls the store actions,
|
||||
* so the gallery is fetched once + cached and adopt/toggle/remove patch local
|
||||
* state instead of re-pulling the network gallery. The floating mascot polls
|
||||
* `pet.info`, so picking a pet here lights it up within a couple seconds.
|
||||
*/
|
||||
export function PetSettings() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.appearance.pet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gallery = useStore($petGallery)
|
||||
const status = useStore($petGalleryStatus)
|
||||
const error = useStore($petGalleryError)
|
||||
const busySlug = useStore($petBusy)
|
||||
const petInfo = useStore($petInfo)
|
||||
const [query, setQuery] = useState('')
|
||||
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
void loadPetGallery(requestGateway)
|
||||
}, [gatewayState, requestGateway])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
const pets = gallery?.pets ?? []
|
||||
const staleBackend = status === 'stale'
|
||||
|
||||
const selectPet = (slug: string) => {
|
||||
void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const removePet = (slug: string) => {
|
||||
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const toggle = (on: boolean) => {
|
||||
void setPetEnabled(requestGateway, on, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
fallback: on ? copy.turnOnFailed : copy.turnOffFailed
|
||||
}).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
// The petdex catalog is thousands of entries, so rank + cap how many render.
|
||||
const RENDER_CAP = 60
|
||||
const sorted = rankedGalleryPets(gallery, query)
|
||||
const shown = sorted.slice(0, RENDER_CAP)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeading icon={PawPrint} title={copy.title} />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.intro}
|
||||
</p>
|
||||
|
||||
{staleBackend && (
|
||||
<p className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.restartHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<input
|
||||
className="mt-3 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder={copy.searchPlaceholder}
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
{/* Fixed-height scroll area so filtering never grows/shrinks the
|
||||
page (no layout thrash); the grid scrolls inside it. */}
|
||||
<div className="mt-3 h-72 overflow-y-auto pr-1">
|
||||
{pets.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.unreachable}
|
||||
</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.noMatch(query)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{shown.map(pet => {
|
||||
const isActive = enabled && active === pet.slug
|
||||
const isBusy = busySlug === pet.slug
|
||||
|
||||
return (
|
||||
<div className="group relative" key={pet.slug}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-50',
|
||||
selectableCardClass({ active: isActive, prominent: pet.installed })
|
||||
)}
|
||||
disabled={isBusy}
|
||||
onClick={() => void selectPet(pet.slug)}
|
||||
type="button"
|
||||
>
|
||||
<PetThumb
|
||||
alt={pet.displayName}
|
||||
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
|
||||
slug={pet.slug}
|
||||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{pet.displayName}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{pet.slug}
|
||||
{pet.installed ? ` · ${copy.installedTag}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
|
||||
</button>
|
||||
{pet.installed && !isBusy && (
|
||||
<button
|
||||
aria-label={copy.uninstall(pet.displayName)}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => void removePet(pet.slug)}
|
||||
title={copy.uninstall(pet.displayName)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Always-present status line so its appearance never shifts layout. */}
|
||||
<p className="mt-2 min-h-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{error ? (
|
||||
<span className="text-(--ui-red)">{error}</span>
|
||||
) : sorted.length > RENDER_CAP ? (
|
||||
copy.countCapped(RENDER_CAP, sorted.length)
|
||||
) : (
|
||||
copy.count(sorted.length)
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
description={copy.chooseDesc}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{copy.chooseTitle}</span>
|
||||
<SegmentedControl
|
||||
onChange={id => void toggle(id === 'on')}
|
||||
options={[
|
||||
{ id: 'off', label: copy.off },
|
||||
{ id: 'on', label: copy.on }
|
||||
]}
|
||||
value={enabled ? 'on' : 'off'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
wide
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
aria-label={copy.scaleTitle}
|
||||
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
|
||||
max={PET_SCALE_MAX}
|
||||
min={PET_SCALE_MIN}
|
||||
onChange={event => {
|
||||
triggerHaptic('selection')
|
||||
setPetScale(requestGateway, Number(event.target.value))
|
||||
}}
|
||||
step={0.05}
|
||||
style={{ accentColor: 'var(--dt-primary)' }}
|
||||
type="range"
|
||||
value={scale}
|
||||
/>
|
||||
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
|
||||
{`${Math.round(scale * 100)}%`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={copy.scaleDesc}
|
||||
title={copy.scaleTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ afterEach(() => {
|
||||
async function renderProvidersSettings() {
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
|
||||
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
|
||||
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
|
||||
}
|
||||
|
||||
describe('ProvidersSettings', () => {
|
||||
@@ -95,6 +95,6 @@ describe('ProvidersSettings', () => {
|
||||
|
||||
expect(await screen.findByText('Qwen Code')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
|
||||
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
|
||||
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { runInTerminal } from '@/app/right-sidebar/store'
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
@@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
|
||||
// The embedded terminal (and thus the "run disconnect command" path) only
|
||||
// exists in the Electron desktop shell, not the web dashboard.
|
||||
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
|
||||
|
||||
// Parallel group headers ("Connected", "Other providers") so the expanded list
|
||||
// reads as its own section instead of bleeding into the connected group.
|
||||
function GroupLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
||||
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
||||
|
||||
@@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
|
||||
function OAuthPicker({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onTerminalDisconnect,
|
||||
onWantApiKey,
|
||||
providers
|
||||
}: {
|
||||
disconnecting: null | string
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onTerminalDisconnect: (provider: OAuthProvider) => void
|
||||
onWantApiKey: () => void
|
||||
providers: OAuthProvider[]
|
||||
}) {
|
||||
@@ -138,15 +156,14 @@ function OAuthPicker({
|
||||
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{p.connected}
|
||||
</p>
|
||||
<GroupLabel>{p.connected}</GroupLabel>
|
||||
{connected.map(p => (
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
onTerminalDisconnect={onTerminalDisconnect}
|
||||
provider={p}
|
||||
/>
|
||||
))}
|
||||
@@ -154,6 +171,7 @@ function OAuthPicker({
|
||||
)}
|
||||
{showOthers && (
|
||||
<>
|
||||
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
|
||||
{others.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
@@ -180,21 +198,26 @@ function ConnectedProviderRow({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onSelect,
|
||||
onTerminalDisconnect,
|
||||
provider
|
||||
}: {
|
||||
disconnecting: boolean
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onSelect: (provider: OAuthProvider) => void
|
||||
onTerminalDisconnect: (provider: OAuthProvider) => void
|
||||
provider: OAuthProvider
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.providers
|
||||
const title = providerTitle(provider)
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
// Hermes can clear this provider's creds via the API.
|
||||
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
|
||||
|
||||
const disconnectHint = provider.flow === 'external'
|
||||
? t.settings.providers.removeExternal(title, provider.cli_command)
|
||||
: t.settings.providers.removeKeyManaged(title)
|
||||
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
|
||||
// command we can run in the embedded terminal (Electron shell only).
|
||||
const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
|
||||
// Only fall back to a static "remove it elsewhere" hint when we offer no button.
|
||||
const showHint = !canDisconnect && !terminalDisconnect
|
||||
|
||||
return (
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||
@@ -203,13 +226,13 @@ function ConnectedProviderRow({
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.settings.providers.connected}
|
||||
{copy.connected}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
{!canDisconnect && (
|
||||
{showHint && (
|
||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||
{disconnectHint}
|
||||
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
@@ -228,6 +251,18 @@ function ConnectedProviderRow({
|
||||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
{terminalDisconnect && (
|
||||
<Button
|
||||
aria-label={`${copy.disconnect} ${title}`}
|
||||
onClick={() => onTerminalDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={copy.disconnectInTerminal}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -243,7 +278,7 @@ function NoProviderKeys() {
|
||||
)
|
||||
}
|
||||
|
||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
||||
export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
@@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
// External (CLI-managed) providers can't be cleared via the API by design —
|
||||
// Hermes never deletes creds another tool owns behind a silent API call.
|
||||
// Instead we run the documented removal command in the embedded terminal so
|
||||
// the user sees exactly what executes, then return them to chat to watch it.
|
||||
function handleTerminalDisconnect(provider: OAuthProvider) {
|
||||
const command = provider.disconnect_command
|
||||
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = providerTitle(provider)
|
||||
|
||||
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
|
||||
return
|
||||
}
|
||||
|
||||
// Leave the settings overlay so the terminal pane (chat-only) is visible.
|
||||
onClose()
|
||||
runInTerminal(command)
|
||||
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
|
||||
}
|
||||
|
||||
async function handleDisconnect(provider: OAuthProvider) {
|
||||
const name = providerTitle(provider)
|
||||
|
||||
@@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => void handleDisconnect(provider)}
|
||||
onTerminalDisconnect={handleTerminalDisconnect}
|
||||
onWantApiKey={() => onViewChange('keys')}
|
||||
providers={oauthProviders}
|
||||
/>
|
||||
@@ -359,6 +418,7 @@ interface ProviderKeyGroup {
|
||||
}
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
onClose: () => void
|
||||
onViewChange: (view: ProviderView) => void
|
||||
view: ProviderView
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
@@ -80,7 +81,10 @@ export function AppShell({
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
const hideTitlebarControls = isNewSessionWindow()
|
||||
// Every secondary window (new-session scratch, subagent watch, cmd-click
|
||||
// pop-out) is a compact side panel — none of them carry the full titlebar
|
||||
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
|
||||
const hideTitlebarControls = isSecondaryWindow()
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
||||
// on macOS, where window controls sit on the left and are reported via
|
||||
@@ -199,6 +203,10 @@ export function AppShell({
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
|
||||
activity. Renders nothing unless a pet is installed + enabled. */}
|
||||
<FloatingPet />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
@@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Command,
|
||||
Hash,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
Zap,
|
||||
ZapFilled
|
||||
} from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -30,16 +27,11 @@ import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$connection,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
$yoloActive,
|
||||
setModelPickerOpen,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
@@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
|
||||
gatewayLogLines: readonly string[]
|
||||
gatewayState: string
|
||||
inferenceStatus: RuntimeReadinessResult | null
|
||||
modelMenuContent?: ReactNode
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
freshDraftReady: boolean
|
||||
@@ -83,7 +74,6 @@ export function useStatusbarItems({
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
freshDraftReady,
|
||||
@@ -97,10 +87,6 @@ export function useStatusbarItems({
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const yoloActive = useStore($yoloActive)
|
||||
const busy = useStore($busy)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
@@ -416,37 +402,6 @@ export function useStatusbarItems({
|
||||
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
id: 'model-summary',
|
||||
label: (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, {
|
||||
fastMode: currentFastMode,
|
||||
reasoningEffort: currentReasoningEffort
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</span>
|
||||
),
|
||||
...(modelMenuContent
|
||||
? {
|
||||
menuAlign: 'end' as const,
|
||||
menuClassName: 'w-64',
|
||||
menuContent: modelMenuContent,
|
||||
title: currentProvider
|
||||
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
|
||||
: copy.switchModel,
|
||||
variant: 'menu' as const
|
||||
}
|
||||
: {
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider
|
||||
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
|
||||
: copy.openModelPicker,
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
{
|
||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
hidden: !chatOpen,
|
||||
@@ -465,11 +420,6 @@ export function useStatusbarItems({
|
||||
contextBar,
|
||||
contextUsage,
|
||||
copy,
|
||||
currentFastMode,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
currentReasoningEffort,
|
||||
modelMenuContent,
|
||||
sessionStartedAt,
|
||||
showYoloToggle,
|
||||
terminalTakeover,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { $modelPresets, getModelPreset } from '@/store/model-presets'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
|
||||
|
||||
// Radix calls these on open; jsdom doesn't implement them.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
$modelPresets.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Render the submenu inside an open menu/sub so its content (switches) mounts.
|
||||
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
|
||||
return render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort="medium"
|
||||
fastControl={opts.fastControl}
|
||||
isActive
|
||||
model="m1"
|
||||
onSelectModel={vi.fn()}
|
||||
provider="p1"
|
||||
reasoning={opts.reasoning}
|
||||
requestGateway={opts.requestGateway as never}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Regression: editing the active row before a live session exists must stay
|
||||
// preset-only — the gateway's config.set falls back to global config when no
|
||||
// session matches, so it must not be called. (Caught in the second review.)
|
||||
describe('ModelEditSubmenu no-session guard', () => {
|
||||
it('param fast: records the preset but skips the gateway without a session', () => {
|
||||
const requestGateway = vi.fn().mockResolvedValue({})
|
||||
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(getModelPreset('p1', 'm1').fast).toBe(true)
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reasoning: records the preset but skips the gateway without a session', () => {
|
||||
const requestGateway = vi.fn().mockResolvedValue({})
|
||||
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
|
||||
|
||||
// Thinking starts on (medium); toggling it off routes through patchReasoning.
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(getModelPreset('p1', 'm1').effort).toBe('none')
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('param fast: pushes to the gateway once a session is active', async () => {
|
||||
const requestGateway = vi.fn().mockResolvedValue({})
|
||||
$activeSessionId.set('sess1')
|
||||
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
|
||||
})
|
||||
})
|
||||
@@ -12,13 +12,9 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { setModelPreset } from '@/store/model-presets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentReasoningEffort,
|
||||
setCurrentFastMode,
|
||||
setCurrentReasoningEffort
|
||||
} from '@/store/session'
|
||||
import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
|
||||
|
||||
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
||||
// by the Thinking toggle, not the radio.
|
||||
@@ -76,96 +72,104 @@ export function resolveFastControl(
|
||||
}
|
||||
|
||||
interface ModelEditSubmenuProps {
|
||||
/** This row's effective reasoning effort (live for the active model, else its
|
||||
* preset) — the submenu shows and edits from this, never the raw session. */
|
||||
effort: string
|
||||
/** How fast mode is offered for this model (param toggle vs. variant swap). */
|
||||
fastControl: FastControl
|
||||
/** Whether this row's model is the active one. */
|
||||
isActive: boolean
|
||||
/** Switch to this model (resolves false on failure). Awaited before applying
|
||||
* edits when not active so a failed switch doesn't write to the old model. */
|
||||
onActivate: () => Promise<boolean> | void
|
||||
/** This row's model id — edits persist as its global preset. */
|
||||
model: string
|
||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||
onSelectModel: (model: string) => Promise<boolean> | void
|
||||
/** This row's provider slug — edits persist as its global preset. */
|
||||
provider: string
|
||||
/** Whether this model supports reasoning effort. */
|
||||
reasoning: boolean
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function ModelEditSubmenu({
|
||||
effort,
|
||||
fastControl,
|
||||
isActive,
|
||||
onActivate,
|
||||
model,
|
||||
onSelectModel,
|
||||
provider,
|
||||
reasoning,
|
||||
requestGateway
|
||||
}: ModelEditSubmenuProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.modelOptions
|
||||
// Reactive session state comes straight from the stores rather than being
|
||||
// drilled through the panel, so editing it re-renders only this submenu.
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
|
||||
const effort = normalizeEffort(currentReasoningEffort)
|
||||
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
|
||||
const effortValue = normalizeEffort(effort)
|
||||
const thinkingOn = isThinkingEnabled(effort)
|
||||
|
||||
// Reasoning/fast are session-scoped (they apply to the active model), so
|
||||
// editing a non-active model first switches to it. Returns false if the
|
||||
// switch failed, so callers skip applying to the wrong (previous) model.
|
||||
const ensureActive = async (): Promise<boolean> => {
|
||||
if (isActive) {
|
||||
return true
|
||||
// Editing always records the model's global preset; the active model also gets
|
||||
// it pushed onto the live session. Non-active edits stay preset-only — they do
|
||||
// not switch you to that model.
|
||||
const patchReasoning = async (next: string) => {
|
||||
setModelPreset(provider, model, { effort: next })
|
||||
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
return (await onActivate()) !== false
|
||||
}
|
||||
|
||||
const patchReasoning = async (next: string, rollback: string) => {
|
||||
setCurrentReasoningEffort(next)
|
||||
|
||||
// Preset-only without a session: `isActive` holds for the global/default
|
||||
// row pre-session, and the gateway's `config.set` falls back to global
|
||||
// config when none matches — so don't reach it (preset + optimistic store
|
||||
// are the whole effect). Same guard in applyModelPreset / toggleFast.
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'reasoning',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: next
|
||||
})
|
||||
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
|
||||
} catch (err) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
setCurrentReasoningEffort(effort)
|
||||
setModelPreset(provider, model, { effort })
|
||||
notifyError(err, copy.updateFailed)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFast = (enabled: boolean) => {
|
||||
if (fastControl.kind === 'variant') {
|
||||
// Fast is a separate model id — swap to it (or back to the base).
|
||||
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
||||
// Fast is a separate model id. Record the choice on the base model's
|
||||
// preset (selectFamily picks the `-fast` sibling later when set), and
|
||||
// only swap models now if this is the active row — inactive edits must
|
||||
// stay preset-only, same as the param path below.
|
||||
setModelPreset(provider, fastControl.baseId, { fast: enabled })
|
||||
|
||||
if (isActive) {
|
||||
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (fastControl.kind === 'param') {
|
||||
setModelPreset(provider, model, { fast: enabled })
|
||||
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentFastMode(enabled)
|
||||
|
||||
// Preset-only without a session (see patchReasoning).
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentFastMode(!enabled)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'fast',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: enabled ? 'fast' : 'normal'
|
||||
})
|
||||
await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
|
||||
} catch (err) {
|
||||
setCurrentFastMode(!enabled)
|
||||
setModelPreset(provider, model, { fast: !enabled })
|
||||
notifyError(err, copy.fastFailed)
|
||||
}
|
||||
})()
|
||||
@@ -188,9 +192,7 @@ export function ModelEditSubmenu({
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked =>
|
||||
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
|
||||
}
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
@@ -205,10 +207,7 @@ export function ModelEditSubmenu({
|
||||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
||||
value={effort}
|
||||
>
|
||||
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={dropdownMenuRow}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
@@ -40,9 +41,14 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
||||
|
||||
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
|
||||
// clicking a model row commits + closes, while the hover-revealed edit submenu
|
||||
// (reasoning/fast) stays open to play with (its items preventDefault on select).
|
||||
export const ModelMenuCloseContext = createContext<() => void>(() => {})
|
||||
|
||||
interface ModelMenuPanelProps {
|
||||
gateway?: HermesGateway
|
||||
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
|
||||
onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
@@ -54,6 +60,7 @@ interface ProviderGroup {
|
||||
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.modelMenu
|
||||
const closeMenu = useContext(ModelMenuCloseContext)
|
||||
const [search, setSearch] = useState('')
|
||||
// Reactive session state is read from the stores here (not drilled in), so
|
||||
// toggling effort/fast/model re-renders this panel in place without forcing
|
||||
@@ -63,6 +70,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const modelPresets = useStore($modelPresets)
|
||||
const visibleModels = useStore($visibleModels)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
@@ -87,13 +95,41 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
: null
|
||||
|
||||
const providers = modelOptions.data?.providers
|
||||
|
||||
const effectiveVisibleModels = useMemo(
|
||||
() => effectiveVisibleKeys(visibleModels, providers ?? []),
|
||||
[visibleModels, providers]
|
||||
)
|
||||
|
||||
const switchTo = (model: string, provider: string) =>
|
||||
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
|
||||
// The composer picker never persists the profile default. With a session it
|
||||
// scopes the switch to that session; with none it's UI state shipped on the
|
||||
// next session.create (see selectModel). The default lives in Settings → Model.
|
||||
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
|
||||
|
||||
// Selecting a model row restores that model's remembered preset onto the
|
||||
// session (effort/fast), gated by capability. Unset → Hermes defaults.
|
||||
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
|
||||
const caps = provider.capabilities?.[family.id]
|
||||
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
|
||||
|
||||
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
|
||||
// id, so honor the saved preset by selecting that sibling. Param-fast is
|
||||
// applied via applyModelPreset below instead.
|
||||
const variantFast = !(caps?.fast ?? false) && !!family.fastId
|
||||
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
|
||||
|
||||
if ((await switchTo(targetId, provider.slug)) === false) {
|
||||
return
|
||||
}
|
||||
|
||||
await applyModelPreset(
|
||||
{
|
||||
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
|
||||
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
|
||||
},
|
||||
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
|
||||
)
|
||||
}
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
|
||||
@@ -152,37 +188,42 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
// -fast variant carries the same param support as its base.
|
||||
const caps = group.provider.capabilities?.[family.id]
|
||||
|
||||
// Single source of truth for the active row's fast state — keeps
|
||||
// the row label in lock-step with the submenu's Fast toggle and
|
||||
// handles the standalone `-fast` id case.
|
||||
// Effective settings for this row: live session state when it's
|
||||
// the active model, otherwise its remembered preset (Hermes
|
||||
// defaults when unset). Row label AND submenu read from these so
|
||||
// they never disagree.
|
||||
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
|
||||
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
|
||||
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
|
||||
|
||||
const fastControl = resolveFastControl(
|
||||
activeId ?? family.id,
|
||||
group.provider.models ?? [],
|
||||
caps?.fast ?? false,
|
||||
currentFastMode
|
||||
effFast
|
||||
)
|
||||
|
||||
// Grayed text is live session state only. Do not label inactive
|
||||
// rows as "Fast" just because they have a fast-capable sibling:
|
||||
// that makes an off Fast toggle look like it is already on.
|
||||
const meta = isCurrent
|
||||
? [
|
||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||
reasoningEffortLabel(currentReasoningEffort) || copy.medium
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: ''
|
||||
const meta = [
|
||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
// Every row is a hover-Edit submenu trigger. Activating it
|
||||
// (pointer or keyboard) switches to the family's base model;
|
||||
// the Fast toggle inside swaps to the -fast sibling (or flips
|
||||
// the speed param). The sub-trigger has no `onSelect`, so wire
|
||||
// both click and Enter/Space for keyboard parity.
|
||||
// (pointer or keyboard) switches to the family's base model and
|
||||
// restores its preset; the Fast toggle inside swaps to the -fast
|
||||
// sibling (or flips the speed param). The sub-trigger has no
|
||||
// `onSelect`, so wire both click and Enter/Space for keyboard parity.
|
||||
// Clicking the row commits the model and closes the picker; the
|
||||
// edit submenu (reasoning/fast) is reached by HOVER, so you can
|
||||
// still tweak those without the click dismissing everything.
|
||||
const activate = () => {
|
||||
if (!isCurrent) {
|
||||
void switchTo(family.id, group.provider.slug)
|
||||
void selectFamily(family, group.provider)
|
||||
}
|
||||
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -204,10 +245,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort={effEffort}
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
onActivate={() => switchTo(family.id, group.provider.slug)}
|
||||
model={family.id}
|
||||
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
||||
provider={group.provider.slug}
|
||||
reasoning={caps?.reasoning ?? true}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
@@ -134,13 +134,20 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
const newSessionWindow = isNewSessionWindow()
|
||||
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
|
||||
const threadContentTopPad = newSessionWindow
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||
// still sit in the top-left, so reserve the titlebar gap above the transcript.
|
||||
const secondaryWindow = isSecondaryWindow()
|
||||
// NB: CSS calc() requires whitespace around the +/- operator. This string is
|
||||
// assigned verbatim to the --sticky-human-top inline style below (it does not
|
||||
// go through Tailwind, which would auto-space it), so the spaces are load-
|
||||
// bearing — without them the declaration is invalid, gets dropped, and the
|
||||
// sticky user bubble falls back to its ~4px default and slides under the OS
|
||||
// traffic lights.
|
||||
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
|
||||
const threadContentTopPad = secondaryWindow
|
||||
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
|
||||
: isSecondaryWindow()
|
||||
? 'pt-6'
|
||||
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
@@ -247,10 +254,21 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
style={
|
||||
{
|
||||
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
|
||||
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
|
||||
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{secondaryWindow && (
|
||||
// Secondary windows hide the titlebar chrome, so the scroller runs to
|
||||
// the window's top edge and streamed text slides up under the OS
|
||||
// traffic lights. Content padding alone scrolls away with the text — a
|
||||
// fixed opaque strip (the titlebar's drag region) masks anything behind
|
||||
// it and keeps the window draggable, matching the main window's header.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-following={isAtBottom ? 'true' : 'false'}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { startManualOnboarding } from '../store/onboarding'
|
||||
|
||||
import { InlineNotice } from './notifications'
|
||||
import { Button } from './ui/button'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import { Skeleton } from './ui/skeleton'
|
||||
@@ -23,7 +22,7 @@ interface ModelPickerDialogProps {
|
||||
sessionId?: string | null
|
||||
currentModel: string
|
||||
currentProvider: string
|
||||
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
|
||||
onSelect: (selection: { provider: string; model: string }) => void
|
||||
/**
|
||||
* Optional class to apply to DialogContent. Use to override z-index when
|
||||
* stacking the picker on top of another fixed overlay (e.g. the desktop
|
||||
@@ -45,7 +44,6 @@ export function ModelPickerDialog({
|
||||
}: ModelPickerDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.modelPicker
|
||||
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
||||
// Own the search term so we can filter manually. cmdk's built-in
|
||||
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
||||
// an empty query), which destroys the backend's curated order. We disable
|
||||
@@ -79,11 +77,7 @@ export function ModelPickerDialog({
|
||||
: null
|
||||
|
||||
const selectModel = (provider: ModelOptionProvider, model: string) => {
|
||||
onSelect({
|
||||
provider: provider.slug,
|
||||
model,
|
||||
persistGlobal: persistGlobal || !sessionId
|
||||
})
|
||||
onSelect({ provider: provider.slug, model })
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@@ -128,24 +122,13 @@ export function ModelPickerDialog({
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={persistGlobal || !sessionId}
|
||||
disabled={!sessionId}
|
||||
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
||||
/>
|
||||
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={addProvider} variant="ghost">
|
||||
{copy.addProvider}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
|
||||
<Button onClick={addProvider} variant="ghost">
|
||||
{copy.addProvider}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { $petInfo, clearPetUnread, type PetInfo, setPetInfo } from '@/store/pet'
|
||||
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { PetSprite } from './pet-sprite'
|
||||
|
||||
// v2: positions are now top/left anchored (v1 stored bottom-anchored values,
|
||||
// which dragged inverted). Bumping the key discards stale v1 coordinates.
|
||||
const POSITION_KEY = 'hermes.desktop.pet-position.v2'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function clampToViewport({ x, y }: Point): Point {
|
||||
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
|
||||
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
|
||||
|
||||
return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }
|
||||
}
|
||||
|
||||
// The sprite art faces left by default, so mirror it when the pet's center sits
|
||||
// on the left half of the window — it always faces inward, toward the content.
|
||||
function facing(leftX: number, petW: number): string {
|
||||
return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none'
|
||||
}
|
||||
|
||||
function loadPosition(): Point {
|
||||
try {
|
||||
const raw = storedString(POSITION_KEY)
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Point
|
||||
|
||||
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
|
||||
return clampToViewport(parsed)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
|
||||
// Default: lower-left corner (top/left anchored).
|
||||
return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 })
|
||||
}
|
||||
|
||||
/**
|
||||
* In-window floating petdex mascot. Always-on-top within the app, draggable,
|
||||
* and reactive to agent activity via `$petState`. Fetches the active pet via
|
||||
* the shared `pet.info` RPC; renders nothing until a pet is installed +
|
||||
* enabled.
|
||||
*
|
||||
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
|
||||
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
|
||||
* while no pet is active and the mascot pops in within a few seconds — no
|
||||
* reload, no CLI. Once a pet is live we stop polling.
|
||||
*
|
||||
* Promotion to a separate frameless OS-level window is a follow-up — the
|
||||
* sprite + state logic here is reused as-is, only the host changes.
|
||||
*/
|
||||
const PET_POLL_MS = 3000
|
||||
|
||||
export function FloatingPet() {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const { resolvedMode } = useTheme()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const info = useStore($petInfo)
|
||||
const overlayActive = useStore($petOverlayActive)
|
||||
|
||||
const [position, setPosition] = useState<Point>(loadPosition)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
// The facing mirror lives on the sprite wrapper, not the container, so the
|
||||
// speech bubble (a container child) never renders flipped/backwards.
|
||||
const spriteWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const petW = (info.frameW ?? 192) * (info.scale ?? 0.33)
|
||||
// Soft contact shadow, sized off the pet so every scale/species grounds the
|
||||
// same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds.
|
||||
const shadowW = Math.round(petW * 0.55)
|
||||
const shadowH = Math.max(3, Math.round(shadowW * 0.28))
|
||||
const shadowAlpha = resolvedMode === 'light' ? 0.2 : 0.55
|
||||
// Live drag offset (pointer → element top-left). Drag updates the DOM
|
||||
// directly to avoid a React re-render (and canvas reflow) per pointermove —
|
||||
// state is only committed on release.
|
||||
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
|
||||
|
||||
// Fetch pet.info on connect, then keep polling while no pet is active so an
|
||||
// in-app `/pet <slug>` shows up live. Stops polling once a pet is enabled.
|
||||
const active = info.enabled && Boolean(info.spritesheetBase64)
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || active) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const pull = async () => {
|
||||
try {
|
||||
const next = await requestGateway<PetInfo>('pet.info')
|
||||
|
||||
if (!cancelled && next) {
|
||||
setPetInfo(next)
|
||||
}
|
||||
} catch {
|
||||
// cosmetic feature — never surface gateway errors
|
||||
}
|
||||
}
|
||||
|
||||
void pull()
|
||||
const timer = window.setInterval(() => void pull(), PET_POLL_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [gatewayState, active, requestGateway])
|
||||
|
||||
// Wire the overlay control channel once, only in the primary window — the
|
||||
// pop-out overlay belongs to it (main.cjs positions it against the main
|
||||
// window and routes control messages back to it).
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
return initPetOverlayBridge()
|
||||
}, [])
|
||||
|
||||
// Returning to the app (by any route, not just the mail icon) clears the pet's
|
||||
// "new message" hint — you've seen it now.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
const onFocus = () => clearPetUnread()
|
||||
window.addEventListener('focus', onFocus)
|
||||
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [])
|
||||
|
||||
// Restore a popped-out pet on boot, once the pet has loaded (so we never spawn
|
||||
// an empty overlay window). Primary window only; runs at most once.
|
||||
const restoredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow() || restoredRef.current || !active) {
|
||||
return
|
||||
}
|
||||
|
||||
restoredRef.current = true
|
||||
restorePetOverlay()
|
||||
}, [active])
|
||||
|
||||
// A window resize must never strand the pet off-screen — re-clamp the
|
||||
// committed position (and persist it) whenever the viewport shrinks.
|
||||
useEffect(() => {
|
||||
const onResize = () =>
|
||||
setPosition(prev => {
|
||||
const next = clampToViewport(prev)
|
||||
|
||||
if (next.x === prev.x && next.y === prev.y) {
|
||||
return prev
|
||||
}
|
||||
|
||||
persistString(POSITION_KEY, JSON.stringify(next))
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
|
||||
// Shift-click pops the pet out into a free-floating desktop overlay (it can
|
||||
// leave the window and stays visible while Hermes is minimized) instead of
|
||||
// starting an in-window drag. Primary window only — the overlay is anchored
|
||||
// to it.
|
||||
if (e.shiftKey && !isSecondaryWindow()) {
|
||||
popOutPet({ height: rect.height, width: rect.width, x: rect.left, y: rect.top })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x: rect.left, y: rect.top }
|
||||
el.setPointerCapture(e.pointerId)
|
||||
el.style.cursor = 'grabbing'
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
const el = containerRef.current
|
||||
|
||||
if (!drag || !el) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
|
||||
drag.x = next.x
|
||||
drag.y = next.y
|
||||
// Mutate the DOM directly — no setState, so no re-render while dragging. The
|
||||
// mirror follows the pointer across the midline for the same reason; it
|
||||
// rides the sprite wrapper so the bubble stays upright.
|
||||
el.style.left = `${next.x}px`
|
||||
el.style.top = `${next.y}px`
|
||||
|
||||
if (spriteWrapRef.current) {
|
||||
spriteWrapRef.current.style.transform = facing(next.x, petW)
|
||||
}
|
||||
},
|
||||
[petW]
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
|
||||
if (drag) {
|
||||
dragRef.current = null
|
||||
const committed = { x: drag.x, y: drag.y }
|
||||
setPosition(committed)
|
||||
persistString(POSITION_KEY, JSON.stringify(committed))
|
||||
}
|
||||
|
||||
const el = containerRef.current
|
||||
|
||||
if (el) {
|
||||
el.style.cursor = 'grab'
|
||||
el.releasePointerCapture?.(e.pointerId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// While popped out, the desktop overlay window owns the mascot — hide the
|
||||
// in-window one so there aren't two.
|
||||
if (!info.enabled || !info.spritesheetBase64 || overlayActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
left: position.x,
|
||||
pointerEvents: 'auto',
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
zIndex: 60
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowAlpha}) 0%, rgba(0,0,0,0) 70%)`,
|
||||
bottom: -shadowH * 0.4,
|
||||
height: shadowH,
|
||||
left: '50%',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
transform: 'translateX(-50%)',
|
||||
width: shadowW,
|
||||
zIndex: 0
|
||||
}}
|
||||
/>
|
||||
<div ref={spriteWrapRef} style={{ lineHeight: 0, position: 'relative', transform: facing(position.x, petW), zIndex: 1 }}>
|
||||
<PetSprite info={info} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { AlertCircle, Clock, type IconComponent } from '@/lib/icons'
|
||||
import { $petActivity, $petState, type PetState } from '@/store/pet'
|
||||
|
||||
/**
|
||||
* Speech bubble + status glyph for the popped-out pet overlay — the
|
||||
* "notification" half of the mascot. It externalizes what the agent is doing
|
||||
* (Codex-style) so a glance at the desktop pet replaces switching back to the
|
||||
* window. The in-window pet doesn't show it (the app itself is the surface);
|
||||
* only the overlay renders it.
|
||||
*
|
||||
* Text is derived purely from the same `$petState` / `$petActivity` the sprite
|
||||
* already reacts to, so it never drifts from the animation. The bubble is shown
|
||||
* only when there's something worth saying (working / reviewing / a transient
|
||||
* done/error beat / waiting on the user) and is hidden at plain idle.
|
||||
*/
|
||||
|
||||
type Tone = 'error' | 'wait'
|
||||
|
||||
interface Spec {
|
||||
lines: string[]
|
||||
glyph?: IconComponent
|
||||
tone?: Tone
|
||||
}
|
||||
|
||||
// Phrasings per mood, picked at random (no immediate repeat) for a bit of life.
|
||||
// Keep them short — the bubble is tiny and never wraps.
|
||||
const SPECS: Partial<Record<PetState, Spec>> = {
|
||||
run: {
|
||||
lines: ['working…', 'on it…', 'crunching…', 'tinkering…', 'cooking…', 'in the weeds…', 'wiring it up…', 'making moves…', 'heads down…', 'hammering away…']
|
||||
},
|
||||
review: {
|
||||
lines: ['thinking…', 'reading…', 'reviewing…', 'pondering…', 'connecting dots…', 'sizing it up…', 'tracing it…', 'mulling…', 'scheming…', 'hmm…']
|
||||
},
|
||||
failed: {
|
||||
glyph: AlertCircle,
|
||||
lines: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'],
|
||||
tone: 'error'
|
||||
},
|
||||
waiting: {
|
||||
glyph: Clock,
|
||||
lines: ['your turn', 'all yours', 'over to you', 'ball’s in your court', 'awaiting orders'],
|
||||
tone: 'wait'
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_COLOR: Record<Tone, string> = {
|
||||
error: 'var(--ui-red)',
|
||||
wait: 'var(--ui-yellow)'
|
||||
}
|
||||
|
||||
// Random pick that avoids repeating the line we're already showing.
|
||||
function pick(lines: string[], prev: string): string {
|
||||
if (lines.length <= 1) {
|
||||
return lines[0] ?? ''
|
||||
}
|
||||
|
||||
let next = prev
|
||||
|
||||
while (next === prev) {
|
||||
next = lines[Math.floor(Math.random() * lines.length)]
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function PetBubble() {
|
||||
const state = useStore($petState)
|
||||
const activity = useStore($petActivity)
|
||||
const [line, setLine] = useState('')
|
||||
|
||||
// Finish beats are carried by the sprite/mail icon; idle only speaks up when
|
||||
// it's actually the user's turn. Everything else maps to a mood spec.
|
||||
const specKey: null | PetState =
|
||||
state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null
|
||||
const rotating = specKey === 'run' || specKey === 'review'
|
||||
|
||||
// Pick a fresh line on every mood change, then keep rotating (random, no
|
||||
// repeat) only while the agent is actively working/thinking.
|
||||
useEffect(() => {
|
||||
const spec = specKey ? SPECS[specKey] : null
|
||||
|
||||
if (!spec) {
|
||||
setLine('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setLine(prev => pick(spec.lines, prev))
|
||||
|
||||
if (!rotating || spec.lines.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [specKey, rotating])
|
||||
|
||||
const spec = specKey ? SPECS[specKey] : null
|
||||
|
||||
if (!spec) {
|
||||
return null
|
||||
}
|
||||
|
||||
const Glyph = spec.glyph
|
||||
const text = line || spec.lines[0]
|
||||
const hasText = Boolean(text)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
|
||||
// `transparent`, so the bubble was see-through).
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: hasText ? 10 : 999,
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
display: 'inline-flex',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
gap: hasText ? 5 : 0,
|
||||
lineHeight: 1,
|
||||
// Glyph-only bubbles collapse to a tight, symmetric badge.
|
||||
padding: hasText ? '5px 8px' : 5,
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{Glyph && (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Glyph style={{ color: spec.tone ? TONE_COLOR[spec.tone] : 'currentColor', height: 13, width: 13 }} />
|
||||
</span>
|
||||
)}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { memo, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { $petState, type PetInfo, type PetState } from '@/store/pet'
|
||||
|
||||
const DEFAULT_FRAME_W = 192
|
||||
const DEFAULT_FRAME_H = 208
|
||||
const DEFAULT_FRAMES = 6
|
||||
const DEFAULT_LOOP_MS = 1100
|
||||
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
|
||||
// the configured scale.
|
||||
const DEFAULT_SCALE = 0.33
|
||||
// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy).
|
||||
const DEFAULT_STATE_ROWS = [
|
||||
'idle',
|
||||
'running-right',
|
||||
'running-left',
|
||||
'waving',
|
||||
'jumping',
|
||||
'failed',
|
||||
'waiting',
|
||||
'running',
|
||||
'review'
|
||||
]
|
||||
|
||||
const STATE_ALIASES: Record<PetState, string[]> = {
|
||||
idle: ['idle'],
|
||||
wave: ['wave', 'waving'],
|
||||
jump: ['jump', 'jumping'],
|
||||
run: ['run', 'running'],
|
||||
failed: ['failed'],
|
||||
review: ['review'],
|
||||
waiting: ['waiting']
|
||||
}
|
||||
|
||||
interface PetSpriteProps {
|
||||
info: PetInfo
|
||||
/** On-screen scale multiplier applied on top of the pet's native scale. */
|
||||
zoom?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas renderer for a petdex spritesheet — the one piece that must be
|
||||
* TypeScript (the engine's decode/encode is Python). Draws the row matching the
|
||||
* live `$petState`, stepping `framesPerState` frames across a `loopMs` loop.
|
||||
*
|
||||
* State is read from `$petState` via a ref + subscription rather than a prop,
|
||||
* so the frequent activity-driven state changes during an agent turn update the
|
||||
* canvas (inside its RAF loop) WITHOUT triggering a React re-render. Combined
|
||||
* with `memo`, this component effectively never re-renders after mount until
|
||||
* the pet itself changes.
|
||||
*/
|
||||
function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
|
||||
const frameW = info.frameW ?? DEFAULT_FRAME_W
|
||||
const frameH = info.frameH ?? DEFAULT_FRAME_H
|
||||
const frames = info.framesPerState ?? DEFAULT_FRAMES
|
||||
const framesByState = info.framesByState
|
||||
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
|
||||
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
|
||||
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
|
||||
|
||||
const drawW = Math.round(frameW * scale)
|
||||
const drawH = Math.round(frameH * scale)
|
||||
|
||||
const image = useMemo(() => {
|
||||
if (!info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}`
|
||||
|
||||
return img
|
||||
}, [info.spritesheetBase64, info.mime])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
|
||||
if (!canvas || !image) {
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track state via subscription, not a prop — no re-render on activity ticks.
|
||||
stateRef.current = $petState.get()
|
||||
|
||||
const unsubState = $petState.listen(next => {
|
||||
stateRef.current = next
|
||||
})
|
||||
|
||||
let raf = 0
|
||||
let frame = 0
|
||||
let lastStep = performance.now()
|
||||
let drawnFrame = -1
|
||||
let drawnRow = -1
|
||||
|
||||
const rowIndexForState = (s: PetState): number => {
|
||||
for (const key of STATE_ALIASES[s] ?? [s]) {
|
||||
const idx = rows.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resolve a state to the row it draws and its real frame count. A state
|
||||
// with no real frames (ragged sheet, empty row) falls back to idle rather
|
||||
// than flashing blank padding.
|
||||
const resolve = (s: PetState): { row: number; count: number } => {
|
||||
const real = framesByState?.[s] ?? frames
|
||||
if (real > 0) {
|
||||
return { row: rowIndexForState(s), count: real }
|
||||
}
|
||||
|
||||
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
|
||||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
const { row, count } = resolve(stateRef.current)
|
||||
// Per-state step keeps every state's loop ~loopMs even when frame counts
|
||||
// differ; counts vary per row so derive the cadence here, not once.
|
||||
const stepMs = loopMs / count
|
||||
|
||||
if (now - lastStep >= stepMs) {
|
||||
frame += 1
|
||||
lastStep = now
|
||||
}
|
||||
|
||||
frame %= count
|
||||
|
||||
// Only touch the canvas when the visible cell actually changes. The RAF
|
||||
// ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of
|
||||
// the clear+draw work and keeps the main thread free.
|
||||
if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) {
|
||||
const sx = frame * frameW
|
||||
const sy = row * frameH
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH)
|
||||
drawnFrame = frame
|
||||
drawnRow = row
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
unsubState()
|
||||
}
|
||||
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
aria-label={info.displayName ? `${info.displayName} pet` : 'pet'}
|
||||
height={drawH}
|
||||
ref={canvasRef}
|
||||
style={{ height: drawH, width: drawW }}
|
||||
width={drawW}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized so a parent re-render (e.g. a position commit on drag-end) doesn't
|
||||
* re-run the canvas setup. Props change only when the pet itself changes.
|
||||
*/
|
||||
export const PetSprite = memo(PetSpriteImpl)
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PawPrint } from '@/lib/icons'
|
||||
|
||||
// petdex frames are a fixed 192×208 grid; the box matches that aspect.
|
||||
const THUMB_W = 40
|
||||
const THUMB_H = Math.round((THUMB_W * 208) / 192)
|
||||
|
||||
export type PetThumbLoader = (slug: string, url?: string) => Promise<string | null>
|
||||
|
||||
/**
|
||||
* Idle-frame preview for one pet. The backend crops + caches the frame and
|
||||
* returns it as a same-origin data URI (`pet.thumb`), which dodges the renderer
|
||||
* CSP / R2 hotlink rules that break a direct `<img src=cdn>`.
|
||||
*/
|
||||
export function PetThumb({
|
||||
slug,
|
||||
url,
|
||||
alt,
|
||||
load,
|
||||
size = THUMB_W
|
||||
}: {
|
||||
slug: string
|
||||
url?: string
|
||||
alt: string
|
||||
load: PetThumbLoader
|
||||
/** Width in px; height follows the petdex frame aspect. */
|
||||
size?: number
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const boxRef = useRef<HTMLSpanElement | null>(null)
|
||||
const height = Math.round((size * 208) / 192)
|
||||
|
||||
useEffect(() => {
|
||||
const el = boxRef.current
|
||||
|
||||
if (!el || src) {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) {
|
||||
observer.disconnect()
|
||||
void load(slug, url).then(uri => {
|
||||
if (uri) {
|
||||
setSrc(uri)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ rootMargin: '120px' }
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [slug, url, src, load])
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid shrink-0 place-items-center overflow-hidden rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)"
|
||||
ref={boxRef}
|
||||
style={{ height, width: size }}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
alt={alt}
|
||||
aria-hidden
|
||||
className="pointer-events-none size-full object-contain"
|
||||
src={src}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<PawPrint className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,12 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
interface CommandInputProps extends React.ComponentProps<typeof CommandPrimitive.Input> {
|
||||
/** Inline trailing slot, rendered on the right of the search row. */
|
||||
right?: React.ReactNode
|
||||
}
|
||||
|
||||
function CommandInput({ className, right, ...props }: CommandInputProps) {
|
||||
return (
|
||||
<div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper">
|
||||
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
@@ -29,6 +34,7 @@ function CommandInput({ className, ...props }: React.ComponentProps<typeof Comma
|
||||
data-slot="command-input"
|
||||
{...props}
|
||||
/>
|
||||
{right}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+21
@@ -1,3 +1,10 @@
|
||||
import type {
|
||||
PetOverlayBounds,
|
||||
PetOverlayControl,
|
||||
PetOverlayOpenRequest,
|
||||
PetOverlayStatePayload
|
||||
} from './store/pet-overlay'
|
||||
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
@@ -26,6 +33,20 @@ declare global {
|
||||
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
|
||||
// Open (or focus) a compact secondary window on the new-session draft.
|
||||
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
|
||||
// The pop-out pet overlay: a transparent always-on-top window hosting only
|
||||
// the mascot. The main renderer drives it (open/close/drag + state push);
|
||||
// the overlay sends control messages back (pop-in, composer submit).
|
||||
petOverlay: {
|
||||
open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }>
|
||||
close: () => Promise<{ ok: boolean }>
|
||||
setBounds: (bounds: PetOverlayBounds) => void
|
||||
setIgnoreMouse: (ignore: boolean) => void
|
||||
setFocusable: (focusable: boolean) => void
|
||||
pushState: (payload: PetOverlayStatePayload) => void
|
||||
control: (payload: PetOverlayControl) => void
|
||||
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
|
||||
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
|
||||
}
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
|
||||
@@ -366,7 +366,32 @@ export const en: Translations = {
|
||||
installError: 'Could not install that theme.',
|
||||
installed: name => `Installed “${name}”.`,
|
||||
removeTheme: 'Remove theme',
|
||||
importedBadge: 'Imported'
|
||||
importedBadge: 'Imported',
|
||||
pet: {
|
||||
title: 'Pet',
|
||||
intro:
|
||||
'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.',
|
||||
restartHint:
|
||||
'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.',
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
scaleTitle: 'Size',
|
||||
scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.',
|
||||
chooseTitle: 'Choose a pet',
|
||||
chooseDesc: 'Picking one installs it (if needed) and makes it active.',
|
||||
searchPlaceholder: 'Search pets…',
|
||||
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
|
||||
noMatch: query => `No pets match "${query}".`,
|
||||
installedTag: 'installed',
|
||||
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
|
||||
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
|
||||
uninstall: name => `Uninstall ${name}`,
|
||||
adoptFailed: slug => `Could not adopt ${slug}`,
|
||||
uninstallFailed: slug => `Could not uninstall ${slug}`,
|
||||
noneAvailable: 'No pets available to turn on right now.',
|
||||
turnOnFailed: 'Could not turn the pet on.',
|
||||
turnOffFailed: 'Could not turn the pet off.'
|
||||
}
|
||||
},
|
||||
fieldLabels: FIELD_LABELS,
|
||||
fieldDescriptions: FIELD_DESCRIPTIONS,
|
||||
@@ -565,9 +590,14 @@ export const en: Translations = {
|
||||
collapse: 'Collapse',
|
||||
connectAnother: 'Connect another provider',
|
||||
otherProviders: 'Other providers',
|
||||
disconnect: 'Disconnect',
|
||||
disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)',
|
||||
removeConfirm: provider => `Remove ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
|
||||
removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`,
|
||||
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
|
||||
removeTerminalConfirm: (provider, command) =>
|
||||
`Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`,
|
||||
removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`,
|
||||
removedTitle: 'Account removed',
|
||||
removedMessage: provider => `${provider} was removed.`,
|
||||
failedRemove: provider => `Could not remove ${provider}`,
|
||||
@@ -705,8 +735,22 @@ export const en: Translations = {
|
||||
commandCenter: 'Command Center',
|
||||
appearance: 'Appearance',
|
||||
settings: 'Settings',
|
||||
changeTheme: 'Change theme...',
|
||||
changeTheme: 'Change theme',
|
||||
changeColorMode: 'Change color mode...',
|
||||
pets: {
|
||||
title: 'Pets',
|
||||
placeholder: 'Search pets…',
|
||||
loading: 'Loading petdex gallery…',
|
||||
error: 'Could not reach the petdex gallery.',
|
||||
staleBackend: 'Restart Hermes to use pets — the backend predates this feature.',
|
||||
empty: 'No matching pets.',
|
||||
turnOff: 'Turn off',
|
||||
turnOn: 'Turn on',
|
||||
installed: 'Installed',
|
||||
adoptFailed: 'Could not adopt that pet.',
|
||||
toggleFailed: 'Could not toggle the pet.',
|
||||
noneAvailable: 'No pets available — pick one below to install.'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'Install theme...',
|
||||
placeholder: 'Search the VS Code Marketplace...',
|
||||
@@ -1498,8 +1542,6 @@ export const en: Translations = {
|
||||
unknown: '(unknown)',
|
||||
search: 'Filter providers and models...',
|
||||
noModels: 'No models found.',
|
||||
persistGlobalSession: 'Persist globally (otherwise this session only)',
|
||||
persistGlobal: 'Persist globally',
|
||||
addProvider: 'Add provider',
|
||||
loadFailed: 'Could not load models',
|
||||
noAuthenticatedProviders: 'No authenticated providers.',
|
||||
|
||||
@@ -281,7 +281,32 @@ export const ja = defineLocale({
|
||||
installError: 'そのテーマをインストールできませんでした。',
|
||||
installed: name => `「${name}」をインストールしました。`,
|
||||
removeTheme: 'テーマを削除',
|
||||
importedBadge: 'インポート済み'
|
||||
importedBadge: 'インポート済み',
|
||||
pet: {
|
||||
title: 'ペット',
|
||||
intro:
|
||||
'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。',
|
||||
restartHint:
|
||||
'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。',
|
||||
scaleTitle: 'サイズ',
|
||||
scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。',
|
||||
on: 'オン',
|
||||
off: 'オフ',
|
||||
chooseTitle: 'ペットを選ぶ',
|
||||
chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。',
|
||||
searchPlaceholder: 'ペットを検索…',
|
||||
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
|
||||
noMatch: query => `「${query}」に一致するペットがありません。`,
|
||||
installedTag: 'インストール済み',
|
||||
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
|
||||
count: n => `${n} 件のペット。`,
|
||||
uninstall: name => `${name} をアンインストール`,
|
||||
adoptFailed: slug => `${slug} を採用できませんでした`,
|
||||
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
|
||||
noneAvailable: 'オンにできるペットがありません。',
|
||||
turnOnFailed: 'ペットをオンにできませんでした。',
|
||||
turnOffFailed: 'ペットをオフにできませんでした。'
|
||||
}
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: 'デフォルトモデル',
|
||||
@@ -695,7 +720,6 @@ export const ja = defineLocale({
|
||||
connectAnother: '別のプロバイダーを接続',
|
||||
otherProviders: 'その他のプロバイダー',
|
||||
removeConfirm: provider => `${provider} を削除しますか?`,
|
||||
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
|
||||
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
|
||||
removedTitle: 'アカウントを削除しました',
|
||||
removedMessage: provider => `${provider} を削除しました。`,
|
||||
@@ -835,8 +859,22 @@ export const ja = defineLocale({
|
||||
commandCenter: 'コマンドセンター',
|
||||
appearance: '外観',
|
||||
settings: '設定',
|
||||
changeTheme: 'テーマを変更...',
|
||||
changeTheme: 'テーマを変更',
|
||||
changeColorMode: 'カラーモードを変更...',
|
||||
pets: {
|
||||
title: 'ペット',
|
||||
placeholder: 'ペットを検索…',
|
||||
loading: 'petdex ギャラリーを読み込み中…',
|
||||
error: 'petdex ギャラリーに接続できません。',
|
||||
staleBackend: 'ペット機能を使うには Hermes を再起動してください。',
|
||||
empty: '一致するペットがありません。',
|
||||
turnOff: 'オフ',
|
||||
turnOn: 'オン',
|
||||
installed: 'インストール済み',
|
||||
adoptFailed: 'ペットを採用できませんでした。',
|
||||
toggleFailed: 'ペットを切り替えできませんでした。',
|
||||
noneAvailable: '利用可能なペットがありません。'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'テーマをインストール...',
|
||||
placeholder: 'VS Code Marketplace を検索...',
|
||||
@@ -1638,8 +1676,6 @@ export const ja = defineLocale({
|
||||
unknown: '(不明)',
|
||||
search: 'プロバイダーとモデルをフィルター...',
|
||||
noModels: 'モデルが見つかりません。',
|
||||
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
|
||||
persistGlobal: 'グローバルに保持',
|
||||
addProvider: 'プロバイダーを追加',
|
||||
loadFailed: 'モデルを読み込めませんでした',
|
||||
noAuthenticatedProviders: '認証済みプロバイダーがありません。',
|
||||
|
||||
@@ -265,6 +265,29 @@ export interface Translations {
|
||||
installed: (name: string) => string
|
||||
removeTheme: string
|
||||
importedBadge: string
|
||||
pet: {
|
||||
title: string
|
||||
intro: string
|
||||
restartHint: string
|
||||
on: string
|
||||
off: string
|
||||
scaleTitle: string
|
||||
scaleDesc: string
|
||||
chooseTitle: string
|
||||
chooseDesc: string
|
||||
searchPlaceholder: string
|
||||
unreachable: string
|
||||
noMatch: (query: string) => string
|
||||
installedTag: string
|
||||
countCapped: (cap: number, total: number) => string
|
||||
count: (n: number) => string
|
||||
uninstall: (name: string) => string
|
||||
adoptFailed: (slug: string) => string
|
||||
uninstallFailed: (slug: string) => string
|
||||
noneAvailable: string
|
||||
turnOnFailed: string
|
||||
turnOffFailed: string
|
||||
}
|
||||
}
|
||||
fieldLabels: Record<string, string>
|
||||
fieldDescriptions: Record<string, string>
|
||||
@@ -447,9 +470,13 @@ export interface Translations {
|
||||
collapse: string
|
||||
connectAnother: string
|
||||
otherProviders: string
|
||||
disconnect: string
|
||||
disconnectInTerminal: string
|
||||
removeConfirm: (provider: string) => string
|
||||
removeExternal: (provider: string, command: string) => string
|
||||
removeExternalGeneric: (provider: string) => string
|
||||
removeKeyManaged: (provider: string) => string
|
||||
removeTerminalConfirm: (provider: string, command: string) => string
|
||||
removeTerminalRunning: (provider: string) => string
|
||||
removedTitle: string
|
||||
removedMessage: (provider: string) => string
|
||||
failedRemove: (provider: string) => string
|
||||
@@ -586,6 +613,20 @@ export interface Translations {
|
||||
settings: string
|
||||
changeTheme: string
|
||||
changeColorMode: string
|
||||
pets: {
|
||||
title: string
|
||||
placeholder: string
|
||||
loading: string
|
||||
error: string
|
||||
staleBackend: string
|
||||
empty: string
|
||||
turnOff: string
|
||||
turnOn: string
|
||||
installed: string
|
||||
adoptFailed: string
|
||||
toggleFailed: string
|
||||
noneAvailable: string
|
||||
}
|
||||
installTheme: {
|
||||
title: string
|
||||
placeholder: string
|
||||
@@ -1141,8 +1182,6 @@ export interface Translations {
|
||||
unknown: string
|
||||
search: string
|
||||
noModels: string
|
||||
persistGlobalSession: string
|
||||
persistGlobal: string
|
||||
addProvider: string
|
||||
loadFailed: string
|
||||
noAuthenticatedProviders: string
|
||||
|
||||
@@ -271,7 +271,30 @@ export const zhHant = defineLocale({
|
||||
installError: '無法安裝該主題。',
|
||||
installed: name => `已安裝「${name}」。`,
|
||||
removeTheme: '移除主題',
|
||||
importedBadge: '已匯入'
|
||||
importedBadge: '已匯入',
|
||||
pet: {
|
||||
title: '寵物',
|
||||
intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
|
||||
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。',
|
||||
scaleTitle: '大小',
|
||||
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
|
||||
on: '開啟',
|
||||
off: '關閉',
|
||||
chooseTitle: '選擇寵物',
|
||||
chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。',
|
||||
searchPlaceholder: '搜尋寵物…',
|
||||
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
|
||||
noMatch: query => `沒有符合「${query}」的寵物。`,
|
||||
installedTag: '已安裝',
|
||||
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
|
||||
count: n => `${n} 個寵物。`,
|
||||
uninstall: name => `解除安裝 ${name}`,
|
||||
adoptFailed: slug => `無法領養 ${slug}`,
|
||||
uninstallFailed: slug => `無法解除安裝 ${slug}`,
|
||||
noneAvailable: '目前沒有可開啟的寵物。',
|
||||
turnOnFailed: '無法開啟寵物。',
|
||||
turnOffFailed: '無法關閉寵物。'
|
||||
}
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '預設模型',
|
||||
@@ -672,7 +695,6 @@ export const zhHant = defineLocale({
|
||||
connectAnother: '連結其他提供方',
|
||||
otherProviders: '其他提供方',
|
||||
removeConfirm: provider => `移除 ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
|
||||
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
|
||||
removedTitle: '帳號已移除',
|
||||
removedMessage: provider => `${provider} 已移除。`,
|
||||
@@ -808,8 +830,22 @@ export const zhHant = defineLocale({
|
||||
commandCenter: '命令中心',
|
||||
appearance: '外觀',
|
||||
settings: '設定',
|
||||
changeTheme: '變更主題...',
|
||||
changeTheme: '變更主題',
|
||||
changeColorMode: '變更色彩模式...',
|
||||
pets: {
|
||||
title: '寵物',
|
||||
placeholder: '搜尋寵物…',
|
||||
loading: '正在載入 petdex 畫廊…',
|
||||
error: '無法連線至 petdex 畫廊。',
|
||||
staleBackend: '請重新啟動 Hermes 以使用寵物功能。',
|
||||
empty: '沒有符合的寵物。',
|
||||
turnOff: '關閉',
|
||||
turnOn: '開啟',
|
||||
installed: '已安裝',
|
||||
adoptFailed: '無法領養該寵物。',
|
||||
toggleFailed: '無法切換寵物顯示。',
|
||||
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安裝主題...',
|
||||
placeholder: '搜尋 VS Code Marketplace...',
|
||||
@@ -1582,8 +1618,6 @@ export const zhHant = defineLocale({
|
||||
unknown: '(未知)',
|
||||
search: '篩選提供方和模型...',
|
||||
noModels: '找不到模型。',
|
||||
persistGlobalSession: '全域儲存(否則僅限此工作階段)',
|
||||
persistGlobal: '全域儲存',
|
||||
addProvider: '新增提供方',
|
||||
loadFailed: '無法載入模型',
|
||||
noAuthenticatedProviders: '沒有已驗證的提供方。',
|
||||
|
||||
@@ -359,7 +359,30 @@ export const zh: Translations = {
|
||||
installError: '无法安装该主题。',
|
||||
installed: name => `已安装「${name}」。`,
|
||||
removeTheme: '移除主题',
|
||||
importedBadge: '已导入'
|
||||
importedBadge: '已导入',
|
||||
pet: {
|
||||
title: '宠物',
|
||||
intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
|
||||
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。',
|
||||
scaleTitle: '大小',
|
||||
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
|
||||
on: '开启',
|
||||
off: '关闭',
|
||||
chooseTitle: '选择宠物',
|
||||
chooseDesc: '选择后会自动安装(如需)并设为当前宠物。',
|
||||
searchPlaceholder: '搜索宠物…',
|
||||
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
|
||||
noMatch: query => `没有匹配「${query}」的宠物。`,
|
||||
installedTag: '已安装',
|
||||
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
|
||||
count: n => `${n} 个宠物。`,
|
||||
uninstall: name => `卸载 ${name}`,
|
||||
adoptFailed: slug => `无法领养 ${slug}`,
|
||||
uninstallFailed: slug => `无法卸载 ${slug}`,
|
||||
noneAvailable: '当前没有可开启的宠物。',
|
||||
turnOnFailed: '无法开启宠物。',
|
||||
turnOffFailed: '无法关闭宠物。'
|
||||
}
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '默认模型',
|
||||
@@ -759,9 +782,13 @@ export const zh: Translations = {
|
||||
collapse: '收起',
|
||||
connectAnother: '连接其他提供方',
|
||||
otherProviders: '其他提供方',
|
||||
disconnect: '断开连接',
|
||||
disconnectInTerminal: '断开连接(在终端中运行移除命令)',
|
||||
removeConfirm: provider => `移除 ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`,
|
||||
removeExternalGeneric: provider => `${provider} 由其自身的 CLI 管理 — 请在那里移除。`,
|
||||
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
|
||||
removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`,
|
||||
removeTerminalRunning: provider => `正在终端中断开 ${provider}…`,
|
||||
removedTitle: '账号已移除',
|
||||
removedMessage: provider => `${provider} 已移除。`,
|
||||
failedRemove: provider => `无法移除 ${provider}`,
|
||||
@@ -896,8 +923,22 @@ export const zh: Translations = {
|
||||
commandCenter: '命令中心',
|
||||
appearance: '外观',
|
||||
settings: '设置',
|
||||
changeTheme: '更改主题...',
|
||||
changeTheme: '更改主题',
|
||||
changeColorMode: '更改颜色模式...',
|
||||
pets: {
|
||||
title: '宠物',
|
||||
placeholder: '搜索宠物…',
|
||||
loading: '正在加载 petdex 画廊…',
|
||||
error: '无法连接到 petdex 画廊。',
|
||||
staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。',
|
||||
empty: '没有匹配的宠物。',
|
||||
turnOff: '关闭',
|
||||
turnOn: '开启',
|
||||
installed: '已安装',
|
||||
adoptFailed: '无法领养该宠物。',
|
||||
toggleFailed: '无法切换宠物显示。',
|
||||
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安装主题...',
|
||||
placeholder: '搜索 VS Code Marketplace...',
|
||||
@@ -1679,8 +1720,6 @@ export const zh: Translations = {
|
||||
unknown: '(未知)',
|
||||
search: '筛选提供方和模型...',
|
||||
noModels: '未找到模型。',
|
||||
persistGlobalSession: '全局保存 (否则仅当前会话)',
|
||||
persistGlobal: '全局保存',
|
||||
addProvider: '添加提供方',
|
||||
loadFailed: '无法加载模型',
|
||||
noAuthenticatedProviders: '没有已认证的提供方。',
|
||||
|
||||
@@ -52,6 +52,16 @@ describe('desktop slash command curation', () => {
|
||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes /pet through the desktop action handler and drops /pets', () => {
|
||||
expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' })
|
||||
expect(resolveDesktopCommand('/pet')?.args).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/pet')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/pet')).toBe(true)
|
||||
expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' })
|
||||
expect(isDesktopSlashSuggestion('/pets')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/pets')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats /browser as an executable action command (local-gateway connect)', () => {
|
||||
// /browser used to be terminal-only; it now resolves to a desktop action
|
||||
// handler that routes browser.manage RPC when the gateway is local.
|
||||
|
||||
@@ -34,6 +34,7 @@ export type DesktopActionId =
|
||||
| 'handoff'
|
||||
| 'help'
|
||||
| 'new'
|
||||
| 'pet'
|
||||
| 'profile'
|
||||
| 'skin'
|
||||
| 'title'
|
||||
@@ -128,6 +129,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
{ name: '/debug', description: 'Create a debug report', surface: exec() },
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
|
||||
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
|
||||
{ name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true },
|
||||
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
|
||||
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
|
||||
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
|
||||
@@ -155,7 +157,7 @@ const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> =
|
||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||
],
|
||||
messaging: ['/approve', '/deny'],
|
||||
settings: ['/skills'],
|
||||
settings: ['/skills', '/pets'],
|
||||
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
IconLoader2 as Loader2Icon,
|
||||
IconLock as Lock,
|
||||
IconLogin as LogIn,
|
||||
IconMail as Mail,
|
||||
IconMessageCircle as MessageCircle,
|
||||
IconMessage2 as MessageSquareText,
|
||||
IconMicrophone as Mic,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
IconLayoutBottombar as PanelBottom,
|
||||
IconLayoutSidebar as PanelLeftIcon,
|
||||
IconPlayerPause as Pause,
|
||||
IconPaw as PawPrint,
|
||||
IconPencil as Pencil,
|
||||
IconPencil as PencilIcon,
|
||||
IconPencil as PencilLine,
|
||||
@@ -153,6 +155,7 @@ export {
|
||||
Loader2Icon,
|
||||
Lock,
|
||||
LogIn,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
@@ -169,6 +172,7 @@ export {
|
||||
PanelBottom,
|
||||
PanelLeftIcon,
|
||||
Pause,
|
||||
PawPrint,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
PencilLine,
|
||||
|
||||
@@ -10,6 +10,11 @@ describe('model-status-label', () => {
|
||||
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
|
||||
})
|
||||
|
||||
it('strips trailing date-pin snapshots from the display name', () => {
|
||||
expect(displayModelName('claude-opus-4-5-20251101')).toBe('Opus 4 5')
|
||||
expect(displayModelName('anthropic/claude-haiku-4-5-20251001')).toBe('Haiku 4 5')
|
||||
})
|
||||
|
||||
it('maps reasoning effort to compact labels', () => {
|
||||
expect(reasoningEffortLabel('high')).toBe('High')
|
||||
expect(reasoningEffortLabel('xhigh')).toBe('Max')
|
||||
|
||||
@@ -68,6 +68,9 @@ export function modelDisplayParts(model: string): { name: string; tag: string }
|
||||
}
|
||||
}
|
||||
|
||||
// Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name.
|
||||
base = base.replace(/-\d{8}$/, '')
|
||||
|
||||
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SelectableCardState {
|
||||
/** Currently selected / active — the strongest emphasis. */
|
||||
active?: boolean
|
||||
/**
|
||||
* Configured / installed / "you have this" — solid surface + border. When
|
||||
* false the card renders muted (transparent, dimmed) until hovered, so the
|
||||
* eye lands on what you already have. Ignored when `active` is set.
|
||||
*/
|
||||
prominent?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared emphasis for selectable list cards across settings surfaces (theme
|
||||
* picker, pet picker, Marketplace results, provider rows…). Three tiers:
|
||||
* active > prominent > muted. Keeps the "installed = solid, not-installed =
|
||||
* quiet" pattern consistent everywhere instead of each picker rolling its own.
|
||||
*
|
||||
* Callers own layout (padding, flex, width); this owns only border + surface.
|
||||
*/
|
||||
export function selectableCardClass({ active, prominent }: SelectableCardState): string {
|
||||
return cn(
|
||||
'rounded-lg border transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary/[0.06] ring-2 ring-primary/20'
|
||||
: prominent
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-quinary)'
|
||||
)
|
||||
}
|
||||
+24
-17
@@ -26,20 +26,27 @@ if (import.meta.env.MODE !== 'production') {
|
||||
import('./app/chat/perf-probe')
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny,
|
||||
// transparent, gateway-less surface instead of the full app. Branch before any
|
||||
// app-shell work so the overlay window stays cheap.
|
||||
if (new URLSearchParams(window.location.search).get('win') === 'overlay') {
|
||||
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
|
||||
} else {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,30 @@ import { atom } from 'nanostores'
|
||||
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
|
||||
export const $commandPaletteOpen = atom(false)
|
||||
|
||||
/** Optional nested page to open when the palette next opens (e.g. `pets`). */
|
||||
export const $commandPalettePage = atom<string | null>(null)
|
||||
|
||||
export function openCommandPalette(): void {
|
||||
$commandPaletteOpen.set(true)
|
||||
}
|
||||
|
||||
/** Open the palette directly on a nested page (`theme`, `pets`, …). */
|
||||
export function openCommandPalettePage(page: string): void {
|
||||
$commandPalettePage.set(page)
|
||||
$commandPaletteOpen.set(true)
|
||||
}
|
||||
|
||||
export function closeCommandPalette(): void {
|
||||
$commandPaletteOpen.set(false)
|
||||
$commandPalettePage.set(null)
|
||||
}
|
||||
|
||||
export function setCommandPaletteOpen(open: boolean): void {
|
||||
$commandPaletteOpen.set(open)
|
||||
|
||||
if (!open) {
|
||||
$commandPalettePage.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleCommandPalette(): void {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets'
|
||||
|
||||
describe('model presets', () => {
|
||||
beforeEach(() => $modelPresets.set({}))
|
||||
|
||||
it('round-trips a preset and merges patches without dropping prior fields', () => {
|
||||
setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' })
|
||||
setModelPreset('anthropic', 'claude-opus-4-8', { fast: true })
|
||||
|
||||
expect(getModelPreset('anthropic', 'claude-opus-4-8')).toEqual({ effort: 'high', fast: true })
|
||||
})
|
||||
|
||||
it('returns an empty preset for unknown models', () => {
|
||||
expect(getModelPreset('x', 'y')).toEqual({})
|
||||
})
|
||||
|
||||
it('keys by provider::model', () => {
|
||||
expect(modelPresetKey('openai', 'gpt-5.5')).toBe('openai::gpt-5.5')
|
||||
})
|
||||
|
||||
it('pushes only the provided dimensions to the gateway', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
|
||||
const request = async <T>(method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
return {} as T
|
||||
}
|
||||
|
||||
await applyModelPreset({ effort: 'high' }, { failMessage: 'x', request, sessionId: 's1' })
|
||||
await applyModelPreset({}, { failMessage: 'x', request, sessionId: 's1' })
|
||||
|
||||
expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }])
|
||||
})
|
||||
|
||||
it('no-ops without a session so selecting a model cannot mutate global config', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
|
||||
const request = async <T>(method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
return {} as T
|
||||
}
|
||||
|
||||
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
|
||||
import { notifyError } from './notifications'
|
||||
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.model-presets'
|
||||
|
||||
/** Per-model reasoning/fast preset, remembered globally across sessions and
|
||||
* re-applied to the session whenever that model is selected. Unset dimensions
|
||||
* fall back to the Hermes default (medium effort, no fast). */
|
||||
export interface ModelPreset {
|
||||
effort?: string
|
||||
fast?: boolean
|
||||
}
|
||||
|
||||
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
/** Stable `provider::model` key (matches the visibility-store format). */
|
||||
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
|
||||
|
||||
function load(): Record<string, ModelPreset> {
|
||||
const raw = storedString(STORAGE_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const $modelPresets = atom<Record<string, ModelPreset>>(load())
|
||||
|
||||
export function getModelPreset(provider: string, model: string): ModelPreset {
|
||||
return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
|
||||
}
|
||||
|
||||
/** Merge a partial preset for one model and persist. */
|
||||
export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
|
||||
const key = modelPresetKey(provider, model)
|
||||
const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
|
||||
|
||||
$modelPresets.set(next)
|
||||
persistString(STORAGE_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
/** Push a model's preset onto the active session (optimistic + gateway).
|
||||
* `undefined` skips that dimension; values are capability-gated upstream.
|
||||
* No-ops without a session — the gateway's `config.set` reasoning/fast fall
|
||||
* back to persistent (global/profile) config when none matches, so selecting
|
||||
* a model must not reach it (else it rewrites `agent.*`, defaults included). */
|
||||
export async function applyModelPreset(
|
||||
{ effort, fast }: ModelPreset,
|
||||
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
|
||||
): Promise<void> {
|
||||
if (!ctx.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (effort !== undefined) {
|
||||
setCurrentReasoningEffort(effort)
|
||||
}
|
||||
|
||||
if (fast !== undefined) {
|
||||
setCurrentFastMode(fast)
|
||||
}
|
||||
|
||||
try {
|
||||
if (effort !== undefined) {
|
||||
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
|
||||
}
|
||||
|
||||
if (fast !== undefined) {
|
||||
await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, ctx.failMessage)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ModelOptionProvider } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
emptyProviderSentinelKey,
|
||||
isProviderSentinel,
|
||||
@@ -78,6 +79,18 @@ describe('model visibility', () => {
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
|
||||
})
|
||||
|
||||
it('folds a date-pinned snapshot into its rolling alias when present', () => {
|
||||
const families = collapseModelFamilies(['claude-opus-4-5', 'claude-opus-4-5-20251101'])
|
||||
|
||||
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5'])
|
||||
})
|
||||
|
||||
it('keeps a date-pinned snapshot standing alone when it has no alias', () => {
|
||||
const families = collapseModelFamilies(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
|
||||
|
||||
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
|
||||
})
|
||||
|
||||
it('sentinel key helper produces correct format', () => {
|
||||
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
|
||||
expect(isProviderSentinel('openai::')).toBe(true)
|
||||
|
||||
@@ -51,6 +51,11 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[]
|
||||
continue
|
||||
}
|
||||
|
||||
if (/-\d{8}$/.test(model) && present.has(model.replace(/-\d{8}$/, ''))) {
|
||||
// A date-pinned snapshot superseded by its rolling alias — drop the dupe.
|
||||
continue
|
||||
}
|
||||
|
||||
const fastId = `${model}-fast`
|
||||
const hasFast = present.has(fastId)
|
||||
families.push({ fastId: hasFast ? fastId : null, id: model })
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { $petInfo, type PetInfo, setPetInfo } from '@/store/pet'
|
||||
|
||||
/**
|
||||
* Feature store for the petdex gallery picker (Cmd+K "Pets…" + Settings).
|
||||
*
|
||||
* Why this exists: `pet.gallery` does a *network* manifest fetch on the gateway,
|
||||
* so re-pulling it after every adopt/toggle made the picker feel laggy and made
|
||||
* two components (palette + settings) each carry their own copy of the same
|
||||
* fetch / thumb-cache / optimistic-mutation logic. This store centralizes it:
|
||||
*
|
||||
* - The gallery is fetched once and cached; reopening the picker is instant.
|
||||
* - Mutations (adopt / enable / remove) patch local state and only re-pull the
|
||||
* cheap, local `pet.info` — never the network manifest again.
|
||||
* - Thumbnails are deduped in a process-global cache (the backend disk-caches
|
||||
* too, so a slug is fetched at most once per session).
|
||||
*
|
||||
* Consumers just `useStore($petGallery)` and call the actions; no component
|
||||
* owns gallery state anymore.
|
||||
*/
|
||||
|
||||
export interface GalleryPet {
|
||||
slug: string
|
||||
displayName: string
|
||||
installed: boolean
|
||||
spritesheetUrl?: string
|
||||
/** petdex's hand-picked set — used only to rank "popular" pets first. */
|
||||
curated?: boolean
|
||||
}
|
||||
|
||||
export interface PetGallery {
|
||||
enabled: boolean
|
||||
active: string
|
||||
pets: GalleryPet[]
|
||||
}
|
||||
|
||||
export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error'
|
||||
|
||||
/** The recovering `requestGateway` from `useGatewayRequest` — passed in so the
|
||||
* store reuses the hook's reconnect/reauth handling instead of duplicating it. */
|
||||
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
/** A JSON-RPC "method not found" — the backend predates the pet RPCs. */
|
||||
function isMissingMethod(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /method not found|-32601|unknown method|no such method/i.test(message)
|
||||
}
|
||||
|
||||
export const $petGallery = atom<PetGallery | null>(null)
|
||||
export const $petGalleryStatus = atom<PetGalleryStatus>('idle')
|
||||
export const $petGalleryError = atom<string | null>(null)
|
||||
|
||||
// Which action is in flight, so rows/buttons can show a spinner. A slug for a
|
||||
// per-pet mutation; the `TOGGLE_*` sentinels for the on/off switch.
|
||||
export const TOGGLE_ON = '\u0000on'
|
||||
export const TOGGLE_OFF = '\u0000off'
|
||||
export const $petBusy = atom<string | null>(null)
|
||||
|
||||
// Process-global caches (survive component unmount → instant reopen).
|
||||
const thumbCache = new Map<string, Promise<string | null>>()
|
||||
let galleryLoad: Promise<void> | null = null
|
||||
|
||||
export function loadPetThumb(request: GatewayRequest, slug: string, url?: string): Promise<string | null> {
|
||||
let pending = thumbCache.get(slug)
|
||||
|
||||
if (!pending) {
|
||||
pending = request<{ ok: boolean; dataUri?: string }>('pet.thumb', { slug, url: url ?? '' })
|
||||
.then(result => (result?.ok && result.dataUri ? result.dataUri : null))
|
||||
.catch(() => null)
|
||||
thumbCache.set(slug, pending)
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the gallery once and cache it. Subsequent calls are no-ops while a
|
||||
* ready snapshot is held; pass `{ force: true }` to bypass the cache (e.g. a
|
||||
* manual refresh). Concurrent callers share a single in-flight request.
|
||||
*/
|
||||
export function loadPetGallery(request: GatewayRequest, options: { force?: boolean } = {}): Promise<void> {
|
||||
if (!options.force && $petGallery.get() && $petGalleryStatus.get() === 'ready') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (galleryLoad) {
|
||||
return galleryLoad
|
||||
}
|
||||
|
||||
galleryLoad = (async () => {
|
||||
if (!$petGallery.get()) {
|
||||
$petGalleryStatus.set('loading')
|
||||
}
|
||||
|
||||
try {
|
||||
const [next, info] = await Promise.all([request<PetGallery>('pet.gallery'), request<PetInfo>('pet.info')])
|
||||
|
||||
if (next) {
|
||||
$petGallery.set(next)
|
||||
$petGalleryStatus.set('ready')
|
||||
$petGalleryError.set(null)
|
||||
}
|
||||
|
||||
if (info) {
|
||||
setPetInfo(info)
|
||||
}
|
||||
} catch (e) {
|
||||
if (isMissingMethod(e)) {
|
||||
$petGalleryStatus.set('stale')
|
||||
} else if (!$petGallery.get()) {
|
||||
// Only surface a hard error when we have nothing to show; a transient
|
||||
// hiccup mid-session leaves the cached gallery intact.
|
||||
$petGalleryStatus.set('error')
|
||||
$petGalleryError.set(e instanceof Error ? e.message : 'Could not reach the petdex gallery.')
|
||||
}
|
||||
} finally {
|
||||
galleryLoad = null
|
||||
}
|
||||
})()
|
||||
|
||||
return galleryLoad
|
||||
}
|
||||
|
||||
// Push the live mascot state (cheap, local config read) without re-pulling the
|
||||
// network gallery — the floating pet repaints, the picker keeps its cache.
|
||||
async function syncInfo(request: GatewayRequest): Promise<void> {
|
||||
try {
|
||||
const info = await request<PetInfo>('pet.info')
|
||||
|
||||
if (info) {
|
||||
setPetInfo(info)
|
||||
}
|
||||
} catch {
|
||||
// The mutation already succeeded; a stale mascot self-heals on its poll.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter (drop the internal `clawd*` pets + apply a search query) and rank the
|
||||
* gallery for a picker. Ranking has no popularity data, so it leans on the
|
||||
* signals we do have: active pet first, then installed, then curated. Shared by
|
||||
* the Cmd-K palette and the Settings grid so the two can't drift — each caller
|
||||
* applies its own cap and reads `.length` for the total.
|
||||
*/
|
||||
export function rankedGalleryPets(gallery: PetGallery | null, query = ''): GalleryPet[] {
|
||||
if (!gallery) {
|
||||
return []
|
||||
}
|
||||
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
const rank = (p: GalleryPet) =>
|
||||
Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated)
|
||||
|
||||
return gallery.pets
|
||||
.filter(
|
||||
p =>
|
||||
!/^clawd(-|$)/i.test(p.slug) &&
|
||||
(!needle || p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle))
|
||||
)
|
||||
.sort((a, b) => rank(b) - rank(a))
|
||||
}
|
||||
|
||||
function patchGallery(fn: (gallery: PetGallery) => PetGallery): void {
|
||||
const current = $petGallery.get()
|
||||
|
||||
if (current) {
|
||||
$petGallery.set(fn(current))
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared mutation wrapper: spin, fire, patch on success, surface failures. */
|
||||
async function mutate(
|
||||
busyKey: string,
|
||||
fallback: string,
|
||||
request: GatewayRequest,
|
||||
run: () => Promise<void>
|
||||
): Promise<boolean> {
|
||||
$petBusy.set(busyKey)
|
||||
$petGalleryError.set(null)
|
||||
|
||||
try {
|
||||
await run()
|
||||
await syncInfo(request)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (isMissingMethod(e)) {
|
||||
$petGalleryStatus.set('stale')
|
||||
} else {
|
||||
$petGalleryError.set(e instanceof Error ? e.message : fallback)
|
||||
}
|
||||
|
||||
return false
|
||||
} finally {
|
||||
$petBusy.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install (if needed) + activate a pet. Optimistically marks it active. */
|
||||
export function adoptPet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
|
||||
return mutate(slug, fallback, request, async () => {
|
||||
await request('pet.select', { slug })
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
enabled: true,
|
||||
active: slug,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: true } : p))
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the floating mascot on/off. On enable, activates the current pet (or the
|
||||
* first installed one). Returns false without firing if there's nothing to show.
|
||||
*/
|
||||
export function setPetEnabled(
|
||||
request: GatewayRequest,
|
||||
on: boolean,
|
||||
copy: { noneAvailable: string; fallback: string }
|
||||
): Promise<boolean> {
|
||||
const gallery = $petGallery.get()
|
||||
|
||||
if (!on && !(gallery?.enabled ?? false)) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
let slug = gallery?.active || ''
|
||||
|
||||
if (on) {
|
||||
slug = slug || gallery?.pets.find(p => p.installed)?.slug || ''
|
||||
|
||||
if (!slug) {
|
||||
$petGalleryError.set(copy.noneAvailable)
|
||||
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
return mutate(on ? TOGGLE_ON : TOGGLE_OFF, copy.fallback, request, async () => {
|
||||
if (on) {
|
||||
await request('pet.select', { slug })
|
||||
} else {
|
||||
await request('pet.disable')
|
||||
}
|
||||
|
||||
patchGallery(g => ({ ...g, enabled: on, active: on ? slug : g.active }))
|
||||
})
|
||||
}
|
||||
|
||||
// Pet scale bounds — mirror `agent/pet/constants.py` (MIN_SCALE / MAX_SCALE) so
|
||||
// the slider and the server clamp to the same range.
|
||||
export const PET_SCALE_MIN = 0.1
|
||||
export const PET_SCALE_MAX = 3.0
|
||||
export const PET_SCALE_DEFAULT = 0.33
|
||||
export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n))
|
||||
|
||||
let scalePersist: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/**
|
||||
* Resize the floating pet. Updates `$petInfo` synchronously so the on-screen pet
|
||||
* (and the slider) react on the same frame, then debounce-persists to
|
||||
* `display.pet.scale` so a slider drag fires one RPC, not one per pixel. No poll
|
||||
* or event needed — the pet already renders from `$petInfo.scale`.
|
||||
*/
|
||||
export function setPetScale(request: GatewayRequest, scale: number): void {
|
||||
const next = clampPetScale(scale)
|
||||
|
||||
setPetInfo({ ...$petInfo.get(), scale: next })
|
||||
|
||||
clearTimeout(scalePersist)
|
||||
scalePersist = setTimeout(() => {
|
||||
request<{ ok: boolean; scale?: number }>('pet.scale', { scale: next })
|
||||
.then(result => {
|
||||
// Reconcile with the server's clamp (cheap; only matters at the bounds).
|
||||
if (typeof result?.scale === 'number' && result.scale !== $petInfo.get().scale) {
|
||||
setPetInfo({ ...$petInfo.get(), scale: result.scale })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Cosmetic — the pet already resized; persistence self-heals next write.
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/** Uninstall a pet; turns the mascot off if it was the active one. */
|
||||
export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
|
||||
return mutate(slug, fallback, request, async () => {
|
||||
await request('pet.remove', { slug })
|
||||
patchGallery(g => ({
|
||||
...g,
|
||||
enabled: g.active === slug ? false : g.enabled,
|
||||
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p))
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
|
||||
import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet'
|
||||
import { $awaitingResponse, $busy } from '@/store/session'
|
||||
|
||||
/**
|
||||
* Controller for the pop-out pet overlay (main-renderer side).
|
||||
*
|
||||
* Shift-clicking the in-window pet "pops it out" into a transparent,
|
||||
* always-on-top OS window (created in electron/main.cjs) that can leave the
|
||||
* app's bounds and stays visible while Hermes is minimized. That window carries
|
||||
* NO gateway connection — this renderer remains the single source of truth and
|
||||
* pushes the live pet state to it over IPC. Control flows back (pop the pet back
|
||||
* in, submit a composer message) via `onControl`.
|
||||
*
|
||||
* The overlay renders the same `PetSprite` / `PetBubble` as the in-window pet by
|
||||
* mirroring the four reactive inputs of `$petState` (`$petInfo`, `$petActivity`,
|
||||
* `$busy`, `$awaitingResponse`) into its own copies of those atoms — so the
|
||||
* popped-out mascot is pixel-identical and needs zero bespoke render logic.
|
||||
*/
|
||||
|
||||
export interface PetOverlayBounds {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to open the overlay window. `screen` says whether `bounds` are already
|
||||
* in absolute screen coordinates (a remembered/dragged spot) or in the main
|
||||
* window's viewport space (a fresh shift-click pop-out, which main.cjs converts
|
||||
* by adding the content origin).
|
||||
*/
|
||||
export interface PetOverlayOpenRequest {
|
||||
bounds: PetOverlayBounds
|
||||
screen?: boolean
|
||||
}
|
||||
|
||||
/** Everything the overlay needs to reproduce the live mascot. */
|
||||
export interface PetOverlayStatePayload {
|
||||
info: PetInfo
|
||||
activity: PetActivity
|
||||
busy: boolean
|
||||
awaiting: boolean
|
||||
/** Drives the overlay's mail icon: a finish landed while you were away. */
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
export type PetOverlayControl =
|
||||
| { type: 'pop-in' }
|
||||
| { type: 'ready' }
|
||||
| { type: 'submit'; text: string }
|
||||
| { type: 'bounds'; bounds: PetOverlayBounds }
|
||||
| { type: 'open-app' }
|
||||
| { type: 'toggle-app' }
|
||||
|
||||
// Persisted across restarts: was the pet popped out, and where on the desktop
|
||||
// did the user leave it. Keyed v1; bump if the bounds shape ever changes.
|
||||
const OVERLAY_ACTIVE_KEY = 'hermes.desktop.pet-overlay-active.v1'
|
||||
const OVERLAY_BOUNDS_KEY = 'hermes.desktop.pet-overlay-bounds.v1'
|
||||
|
||||
export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false))
|
||||
|
||||
// Persist the in/out choice so a popped-out pet comes back popped out.
|
||||
$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active))
|
||||
|
||||
function loadSavedBounds(): null | PetOverlayBounds {
|
||||
try {
|
||||
const raw = storedString(OVERLAY_BOUNDS_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<PetOverlayBounds>
|
||||
|
||||
if (
|
||||
typeof parsed.x === 'number' &&
|
||||
typeof parsed.y === 'number' &&
|
||||
typeof parsed.width === 'number' &&
|
||||
typeof parsed.height === 'number'
|
||||
) {
|
||||
return { height: parsed.height, width: parsed.width, x: parsed.x, y: parsed.y }
|
||||
}
|
||||
} catch {
|
||||
// fall through to null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function saveBounds(bounds: PetOverlayBounds): void {
|
||||
persistString(OVERLAY_BOUNDS_KEY, JSON.stringify(bounds))
|
||||
}
|
||||
|
||||
// The overlay window is padded around the sprite so the bubble (above), the
|
||||
// drag area, and the pop-up composer all have room; the pet sits near the
|
||||
// bottom and the rest of the rectangle is transparent + click-through.
|
||||
const OVERLAY_PAD_X = 100
|
||||
const OVERLAY_PAD_Y = 200
|
||||
const OVERLAY_MIN_W = 240
|
||||
const OVERLAY_MIN_H = 300
|
||||
|
||||
let stateUnsubs: Array<() => void> = []
|
||||
let controlUnsub: (() => void) | null = null
|
||||
let submitHandler: ((text: string) => void) | null = null
|
||||
let openAppHandler: (() => void) | null = null
|
||||
|
||||
function currentPayload(): PetOverlayStatePayload {
|
||||
return {
|
||||
info: $petInfo.get(),
|
||||
activity: $petActivity.get(),
|
||||
busy: $busy.get(),
|
||||
awaiting: $awaitingResponse.get(),
|
||||
unread: $petUnread.get()
|
||||
}
|
||||
}
|
||||
|
||||
function pushNow(): void {
|
||||
window.hermesDesktop?.petOverlay?.pushState(currentPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the overlay window and start mirroring live state into it. The main
|
||||
* process echoes back the actual screen bounds it used, which we persist so the
|
||||
* pet reopens exactly where the user left it.
|
||||
*/
|
||||
function openOverlay(request: PetOverlayOpenRequest): void {
|
||||
const api = window.hermesDesktop?.petOverlay
|
||||
|
||||
if (!api || stateUnsubs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
$petOverlayActive.set(true)
|
||||
void api.open(request).then(res => {
|
||||
if (res?.bounds) {
|
||||
saveBounds(res.bounds)
|
||||
}
|
||||
|
||||
pushNow()
|
||||
})
|
||||
|
||||
// Mirror live state into the overlay. subscribe() fires immediately, so the
|
||||
// overlay also gets a first frame the moment it's ready (it asks via 'ready').
|
||||
stateUnsubs = [
|
||||
$petInfo.subscribe(pushNow),
|
||||
$petActivity.subscribe(pushNow),
|
||||
$busy.subscribe(pushNow),
|
||||
$awaitingResponse.subscribe(pushNow),
|
||||
$petUnread.subscribe(pushNow)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the pet out of the window. `petRect` is the in-window sprite's viewport
|
||||
* rect; we grow it to the padded overlay size and center the window on the
|
||||
* pet's old spot (main.cjs adds the window's screen origin). If the user has
|
||||
* popped out before, reopen at that remembered desktop spot instead.
|
||||
*/
|
||||
export function popOutPet(petRect: PetOverlayBounds): void {
|
||||
if ($petOverlayActive.get() || stateUnsubs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const saved = loadSavedBounds()
|
||||
|
||||
if (saved) {
|
||||
openOverlay({ bounds: saved, screen: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X))
|
||||
const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y))
|
||||
const x = Math.round(petRect.x - (width - petRect.width) / 2)
|
||||
const y = Math.round(petRect.y - (height - petRect.height) / 2)
|
||||
|
||||
openOverlay({ bounds: { height, width, x, y }, screen: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the overlay on boot if the pet was popped out when the app last
|
||||
* closed. Requires a remembered desktop spot — without one we fall back to the
|
||||
* in-window pet rather than spawning an orphan window at the origin.
|
||||
*/
|
||||
export function restorePetOverlay(): void {
|
||||
if (!window.hermesDesktop?.petOverlay || !$petOverlayActive.get() || stateUnsubs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const saved = loadSavedBounds()
|
||||
|
||||
if (!saved) {
|
||||
$petOverlayActive.set(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
openOverlay({ bounds: saved, screen: true })
|
||||
}
|
||||
|
||||
/** Pop the pet back into the window (closes the overlay window). */
|
||||
export function popInPet(): void {
|
||||
for (const off of stateUnsubs) {
|
||||
off()
|
||||
}
|
||||
|
||||
stateUnsubs = []
|
||||
$petOverlayActive.set(false)
|
||||
void window.hermesDesktop?.petOverlay?.close()
|
||||
}
|
||||
|
||||
/** Register the handler that turns an overlay composer submit into a real send. */
|
||||
export function setPetOverlaySubmitHandler(fn: ((text: string) => void) | null): void {
|
||||
submitHandler = fn
|
||||
}
|
||||
|
||||
/** Register the handler that opens the app to the most recent thread (mail icon). */
|
||||
export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void {
|
||||
openAppHandler = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the overlay→renderer control channel once. Returns a disposer. Idempotent
|
||||
* — a second call while already wired is a no-op.
|
||||
*/
|
||||
export function initPetOverlayBridge(): () => void {
|
||||
const api = window.hermesDesktop?.petOverlay
|
||||
|
||||
if (!api || controlUnsub) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
controlUnsub = api.onControl(payload => {
|
||||
if (payload?.type === 'pop-in') {
|
||||
popInPet()
|
||||
} else if (payload?.type === 'ready') {
|
||||
// The overlay just mounted — hand it the current frame.
|
||||
pushNow()
|
||||
} else if (payload?.type === 'submit' && typeof payload.text === 'string') {
|
||||
submitHandler?.(payload.text)
|
||||
} else if (payload?.type === 'bounds' && payload.bounds) {
|
||||
// The user dragged the overlay to a new desktop spot — remember it.
|
||||
saveBounds(payload.bounds)
|
||||
} else if (payload?.type === 'open-app') {
|
||||
// Mail icon: surface the app on the most recent thread (main.cjs already
|
||||
// focused the window before forwarding this) and mark it read.
|
||||
clearPetUnread()
|
||||
openAppHandler?.()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
controlUnsub?.()
|
||||
controlUnsub = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet'
|
||||
|
||||
describe('derivePetState', () => {
|
||||
it('rests at idle by default and uses waiting when awaiting input', () => {
|
||||
expect(derivePetState({})).toBe('idle')
|
||||
expect(derivePetState({ awaitingInput: true })).toBe('waiting')
|
||||
})
|
||||
|
||||
it('runs when busy or a tool is executing', () => {
|
||||
expect(derivePetState({ busy: true })).toBe('run')
|
||||
expect(derivePetState({ toolRunning: true })).toBe('run')
|
||||
})
|
||||
|
||||
it('reviews while reasoning (below tool, above bare busy)', () => {
|
||||
expect(derivePetState({ reasoning: true })).toBe('review')
|
||||
expect(derivePetState({ reasoning: true, busy: true })).toBe('review')
|
||||
expect(derivePetState({ reasoning: true, toolRunning: true })).toBe('run')
|
||||
})
|
||||
|
||||
it('waits (blocked on the user) above the in-flight signals', () => {
|
||||
expect(derivePetState({ awaitingInput: true, toolRunning: true, busy: true })).toBe('waiting')
|
||||
// but a finish beat still wins over waiting
|
||||
expect(derivePetState({ justCompleted: true, awaitingInput: true })).toBe('wave')
|
||||
})
|
||||
|
||||
it('honors the full priority chain: error > celebrate > complete > tool', () => {
|
||||
expect(derivePetState({ error: true, celebrate: true, busy: true })).toBe('failed')
|
||||
expect(derivePetState({ celebrate: true, justCompleted: true, toolRunning: true })).toBe('jump')
|
||||
expect(derivePetState({ justCompleted: true, toolRunning: true })).toBe('wave')
|
||||
})
|
||||
})
|
||||
|
||||
describe('flashPetActivity', () => {
|
||||
it('clears stale sibling beats so a completion never inherits a prior error', () => {
|
||||
// A turn errors (sad), then the next turn finishes cleanly. The celebrate
|
||||
// beat must win — error is highest priority, so a merge-only flash would
|
||||
// keep the pet on the failed pose.
|
||||
setPetActivity({ error: true })
|
||||
flashPetActivity({ celebrate: true })
|
||||
|
||||
expect($petActivity.get().error).toBe(false)
|
||||
expect($petState.get()).toBe('jump')
|
||||
|
||||
setPetActivity({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $busy } from '@/store/session'
|
||||
|
||||
/**
|
||||
* Petdex mascot state for the desktop floating pet.
|
||||
*
|
||||
* The spritesheet payload comes from the gateway `pet.info` RPC (shared with
|
||||
* the TUI). The animation *state* is derived here from the same activity
|
||||
* signals the chat already tracks, mirroring the priority order documented in
|
||||
* `agent/pet/state.py` so the Python and TS surfaces never drift.
|
||||
*/
|
||||
|
||||
export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting'
|
||||
|
||||
export interface PetInfo {
|
||||
enabled: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
mime?: string
|
||||
spritesheetBase64?: string
|
||||
frameW?: number
|
||||
frameH?: number
|
||||
framesPerState?: number
|
||||
// Real (padding-trimmed) frame count per state row, from the engine. Lets the
|
||||
// canvas step only frames that exist instead of a fixed framesPerState, which
|
||||
// would animate into the transparent padding of ragged sheets (blank flash).
|
||||
framesByState?: Record<string, number>
|
||||
loopMs?: number
|
||||
scale?: number
|
||||
stateRows?: string[]
|
||||
}
|
||||
|
||||
export interface PetActivity {
|
||||
busy?: boolean
|
||||
awaitingInput?: boolean
|
||||
toolRunning?: boolean
|
||||
reasoning?: boolean
|
||||
error?: boolean
|
||||
justCompleted?: boolean
|
||||
celebrate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the animation state from coarse activity signals.
|
||||
*
|
||||
* Priority (highest first) mirrors `agent.pet.state.derive_pet_state`:
|
||||
* error → celebrate → justCompleted → awaitingInput → toolRunning → reasoning →
|
||||
* busy → idle. `awaitingInput` (a clarify/approval blocking on the user) outranks
|
||||
* the in-flight signals because the turn is paused on you, not working.
|
||||
*/
|
||||
export function derivePetState(activity: PetActivity): PetState {
|
||||
if (activity.error) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
if (activity.celebrate) {
|
||||
return 'jump'
|
||||
}
|
||||
|
||||
if (activity.justCompleted) {
|
||||
return 'wave'
|
||||
}
|
||||
|
||||
if (activity.awaitingInput) {
|
||||
return 'waiting'
|
||||
}
|
||||
|
||||
if (activity.toolRunning) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
if (activity.reasoning) {
|
||||
return 'review'
|
||||
}
|
||||
|
||||
if (activity.busy) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export const $petInfo = atom<PetInfo>({ enabled: false })
|
||||
export const $petActivity = atom<PetActivity>({})
|
||||
|
||||
/**
|
||||
* Pet-local "you have a new message" flag, surfaced as the overlay's mail icon.
|
||||
* Deliberately not real unread tracking: it flips on when a turn finishes while
|
||||
* the app isn't focused, and off when the user opens the app via the mail icon
|
||||
* (or returns to the window). No persistence — it's a glance hint, not state.
|
||||
*/
|
||||
export const $petUnread = atom(false)
|
||||
export const markPetUnread = () => $petUnread.set(true)
|
||||
export const clearPetUnread = () => $petUnread.set(false)
|
||||
|
||||
/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */
|
||||
export const setPetActivity = (next: Partial<PetActivity>) =>
|
||||
$petActivity.set({ ...$petActivity.get(), ...next })
|
||||
|
||||
let flashTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/** Fire a transient reaction beat (error / celebrate / justCompleted) that
|
||||
* decays back to the steady state after `ms`.
|
||||
*
|
||||
* Each beat first clears its siblings so a stale one can't win the priority
|
||||
* race: without this, a completion beat (`celebrate`) would merge on top of a
|
||||
* lingering `error`, and `derivePetState` checks `error` first — so a clean
|
||||
* finish would render the sad/failed pose. */
|
||||
export const flashPetActivity = (next: Partial<PetActivity>, ms = 1600) => {
|
||||
setPetActivity({ celebrate: false, error: false, justCompleted: false, ...next })
|
||||
clearTimeout(flashTimer)
|
||||
flashTimer = setTimeout(
|
||||
() => setPetActivity({ celebrate: false, error: false, justCompleted: false }),
|
||||
ms
|
||||
)
|
||||
}
|
||||
|
||||
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
|
||||
|
||||
/**
|
||||
* The live pet state. Derives from the dedicated activity atom, falling back to
|
||||
* the always-present `$busy` chat signal so the pet reacts out of the box.
|
||||
*
|
||||
* `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag
|
||||
* on `$petActivity` — set by the controller from `$attentionSessionIds` and
|
||||
* mirrored to the pop-out overlay through the same atom, so both surfaces agree
|
||||
* without the overlay needing the session list.
|
||||
*/
|
||||
export const $petState = computed(
|
||||
[$petActivity, $busy],
|
||||
(activity, busy): PetState => {
|
||||
const live = activity.busy ?? busy
|
||||
|
||||
return derivePetState({
|
||||
busy: live,
|
||||
awaitingInput: activity.awaitingInput,
|
||||
// Steady flags only count mid-turn — ignore stale ones once at rest so an
|
||||
// interrupted turn can't pin the pet on `run`/`review`.
|
||||
toolRunning: live && activity.toolRunning,
|
||||
reasoning: live && activity.reasoning,
|
||||
error: activity.error,
|
||||
justCompleted: activity.justCompleted,
|
||||
celebrate: activity.celebrate
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -4,13 +4,23 @@ import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
|
||||
import type { SessionInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
// The composer's model/effort/fast is sticky UI state, NOT the profile default
|
||||
// (that lives in Settings → Model). Persisting it in localStorage makes a pick
|
||||
// follow across Cmd+N and app restarts instead of snapping back to the default.
|
||||
// It's deliberately global (not per-profile): a profile switch force-reseeds to
|
||||
// that profile's default, while within a profile new chats keep your last pick.
|
||||
const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'
|
||||
const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
|
||||
const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
|
||||
const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
|
||||
|
||||
let configuredDefaultProjectDir = ''
|
||||
|
||||
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
|
||||
@@ -208,11 +218,11 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
|
||||
export const $freshDraftReady = atom(false)
|
||||
export const $busy = atom(false)
|
||||
export const $awaitingResponse = atom(false)
|
||||
export const $currentModel = atom('')
|
||||
export const $currentProvider = atom('')
|
||||
export const $currentReasoningEffort = atom('')
|
||||
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
|
||||
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
|
||||
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
|
||||
export const $currentServiceTier = atom('')
|
||||
export const $currentFastMode = atom(false)
|
||||
export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
|
||||
// Effective approval-bypass state mirrored from the gateway (session.info).
|
||||
// Persistence lives in the backend config (approvals.mode), so this is a plain
|
||||
// reflection of the truth the gateway reports rather than its own store.
|
||||
@@ -254,11 +264,29 @@ export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($message
|
||||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||||
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
||||
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||||
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
|
||||
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
|
||||
|
||||
export const setCurrentModel = (next: Updater<string>) => {
|
||||
updateAtom($currentModel, next)
|
||||
persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null)
|
||||
}
|
||||
|
||||
export const setCurrentProvider = (next: Updater<string>) => {
|
||||
updateAtom($currentProvider, next)
|
||||
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
|
||||
}
|
||||
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => {
|
||||
updateAtom($currentReasoningEffort, next)
|
||||
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
|
||||
}
|
||||
|
||||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
|
||||
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => {
|
||||
updateAtom($currentFastMode, next)
|
||||
persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get())
|
||||
}
|
||||
|
||||
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { DesktopUpdateStatus } from '@/global'
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
vi.mock('@/lib/storage', () => ({
|
||||
persistBoolean: (key: string, value: boolean) => {
|
||||
storage.set(key, String(value))
|
||||
},
|
||||
persistString: (key: string, value: null | string) => {
|
||||
if (value === null) {
|
||||
storage.delete(key)
|
||||
@@ -12,6 +15,11 @@ vi.mock('@/lib/storage', () => ({
|
||||
storage.set(key, value)
|
||||
}
|
||||
},
|
||||
storedBoolean: (key: string, fallback: boolean) => {
|
||||
const value = storage.get(key)
|
||||
|
||||
return value === undefined ? fallback : value === 'true'
|
||||
},
|
||||
storedString: (key: string) => storage.get(key) ?? null
|
||||
}))
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface OAuthProviderStatus {
|
||||
|
||||
export interface OAuthProvider {
|
||||
cli_command: string
|
||||
/** Shell command that clears an external provider's credentials, run in the
|
||||
* embedded terminal. Null when Hermes doesn't know how to remove it. */
|
||||
disconnect_command?: null | string
|
||||
disconnect_hint?: null | string
|
||||
disconnectable?: boolean
|
||||
docs_url: string
|
||||
|
||||
@@ -60,7 +60,7 @@ from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.styles import Style as PTStyle
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer
|
||||
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer, WindowAlign
|
||||
from prompt_toolkit.layout.processors import Processor, Transformation, PasswordProcessor, ConditionalProcessor
|
||||
from prompt_toolkit.filters import Condition
|
||||
from prompt_toolkit.layout.dimension import Dimension
|
||||
@@ -3572,6 +3572,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup)
|
||||
self._command_running = False
|
||||
self._command_status = ""
|
||||
# Petdex mascot (opt-in via display.pet). The base CLI mirrors the TUI's
|
||||
# PetPane: a half-block sprite above the prompt that reacts to agent
|
||||
# activity. Lazily resolved; an invalidate timer drives the animation.
|
||||
self._pet_renderer = None # agent.pet.render.PetRenderer | None
|
||||
self._pet_slug: str = ""
|
||||
self._pet_enabled: bool = False
|
||||
self._pet_cols: int = 18
|
||||
self._pet_scale: float = 0.7
|
||||
self._pet_frames_cache: dict = {} # state -> list[grid]
|
||||
self._pet_frame_idx: int = 0
|
||||
self._pet_lock = threading.Lock()
|
||||
self._pet_cfg_checked: float = 0.0
|
||||
self._pet_anim_running: bool = False
|
||||
self._pet_anim_thread = None
|
||||
# Transient reaction beats (wave/jump/failed) + steady reasoning flag.
|
||||
self._pet_event: str = ""
|
||||
self._pet_event_until: float = 0.0
|
||||
self._pet_reasoning: bool = False
|
||||
self._pet_turn_error: bool = False
|
||||
self._attached_images: list[Path] = []
|
||||
self._image_counter = 0
|
||||
self.preloaded_skills: list[str] = []
|
||||
@@ -4112,6 +4131,218 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
return f" {txt} ({elapsed_str})"
|
||||
return f" {txt}"
|
||||
|
||||
# ── Petdex mascot (base-CLI pet pane) ───────────────────────────────
|
||||
#
|
||||
# Parity with the TUI: a half-block sprite rendered as a prompt_toolkit
|
||||
# window above the prompt, reacting to agent state and animated by a timer
|
||||
# that calls ``app.invalidate()``. Half-blocks only — the crisp Kitty image
|
||||
# protocol can't coexist with prompt_toolkit's patch_stdout output layer
|
||||
# (raw image escapes get swallowed/mangled), so we use truecolor styled
|
||||
# text, which prompt_toolkit renders natively in any 24-bit terminal.
|
||||
|
||||
_PET_FRAME_INTERVAL = 0.16
|
||||
_PET_CFG_INTERVAL = 2.5
|
||||
|
||||
def _pet_resolve_config(self) -> None:
|
||||
"""(Re)resolve the active pet from config — picks up live enable/disable/
|
||||
|
||||
switch made via ``/pet`` or ``hermes pets`` without a restart, mirroring
|
||||
the TUI's steady poll. Cheap and fail-open: any problem disables the pet.
|
||||
"""
|
||||
try:
|
||||
from agent.pet import constants, store
|
||||
from agent.pet.render import PetRenderer
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
||||
|
||||
enabled = bool(pet_cfg.get("enabled"))
|
||||
slug = str(pet_cfg.get("slug", "") or "")
|
||||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
|
||||
|
||||
if not enabled:
|
||||
with self._pet_lock:
|
||||
self._pet_enabled = False
|
||||
self._pet_renderer = None
|
||||
self._pet_frames_cache.clear()
|
||||
return
|
||||
|
||||
pet = store.resolve_active_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
with self._pet_lock:
|
||||
self._pet_enabled = False
|
||||
self._pet_renderer = None
|
||||
self._pet_frames_cache.clear()
|
||||
return
|
||||
|
||||
with self._pet_lock:
|
||||
# Rebuild only when the resolved pet or geometry changes.
|
||||
if (
|
||||
self._pet_renderer is None
|
||||
or self._pet_slug != pet.slug
|
||||
or self._pet_cols != cols
|
||||
or self._pet_scale != scale
|
||||
):
|
||||
self._pet_renderer = PetRenderer(
|
||||
str(pet.spritesheet), mode="unicode", scale=scale, unicode_cols=cols
|
||||
)
|
||||
self._pet_slug = pet.slug
|
||||
self._pet_cols = cols
|
||||
self._pet_scale = scale
|
||||
self._pet_frames_cache.clear()
|
||||
self._pet_frame_idx = 0
|
||||
self._pet_enabled = True
|
||||
except Exception:
|
||||
with self._pet_lock:
|
||||
self._pet_enabled = False
|
||||
self._pet_renderer = None
|
||||
|
||||
def _pet_flash(self, state: str, secs: float = 1.6) -> None:
|
||||
"""Briefly force a transient reaction (wave/jump/failed) before resting."""
|
||||
self._pet_event = state
|
||||
self._pet_event_until = time.monotonic() + secs
|
||||
|
||||
def _pet_react_turn_end(self) -> None:
|
||||
"""Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave."""
|
||||
if not self._pet_enabled:
|
||||
return
|
||||
from agent.pet.state import todos_all_done
|
||||
|
||||
if self._pet_turn_error:
|
||||
self._pet_flash("failed")
|
||||
return
|
||||
try:
|
||||
store = getattr(self.agent, "_todo_store", None)
|
||||
done = todos_all_done(store.read()) if store else False
|
||||
except Exception:
|
||||
done = False
|
||||
self._pet_flash("jump" if done else "wave")
|
||||
|
||||
def _derive_pet_state(self) -> str:
|
||||
"""Map current CLI activity to a pet animation state.
|
||||
|
||||
A transient reaction beat (wave/jump/failed) wins while it's live;
|
||||
otherwise the steady state comes from the shared
|
||||
:func:`agent.pet.state.derive_pet_state` so the CLI can't drift from the
|
||||
TUI/desktop priority order.
|
||||
"""
|
||||
if self._pet_event and time.monotonic() < self._pet_event_until:
|
||||
return self._pet_event
|
||||
self._pet_event = ""
|
||||
from agent.pet.state import derive_pet_state
|
||||
|
||||
# A live blocking modal (approval / clarify / sudo / secret / slash
|
||||
# confirm) means the agent is paused on the user → the `waiting` pose,
|
||||
# which outranks the in-flight signals in derive_pet_state.
|
||||
awaiting_input = bool(
|
||||
self._approval_state
|
||||
or self._clarify_state
|
||||
or self._sudo_state
|
||||
or self._secret_state
|
||||
or getattr(self, "_slash_confirm_state", None)
|
||||
)
|
||||
|
||||
return derive_pet_state(
|
||||
awaiting_input=awaiting_input,
|
||||
busy=getattr(self, "_agent_running", False),
|
||||
reasoning=self._pet_reasoning,
|
||||
).value
|
||||
|
||||
def _pet_frames_for(self, state: str) -> list:
|
||||
"""Return (and cache) the half-block grids for one state."""
|
||||
cached = self._pet_frames_cache.get(state)
|
||||
if cached is not None:
|
||||
return cached
|
||||
renderer = self._pet_renderer
|
||||
if renderer is None:
|
||||
return []
|
||||
try:
|
||||
count = renderer.frame_count(state) or 1
|
||||
grids = [renderer.cells(state, i, cols=self._pet_cols) for i in range(count)]
|
||||
except Exception:
|
||||
grids = []
|
||||
self._pet_frames_cache[state] = grids
|
||||
return grids
|
||||
|
||||
def _pet_fragments(self):
|
||||
"""Return prompt_toolkit FormattedText for the current pet frame, or []."""
|
||||
with self._pet_lock:
|
||||
if not self._pet_enabled or self._pet_renderer is None:
|
||||
return []
|
||||
state = self._derive_pet_state()
|
||||
grids = self._pet_frames_for(state)
|
||||
if not grids:
|
||||
return []
|
||||
grid = grids[self._pet_frame_idx % len(grids)]
|
||||
|
||||
frags = []
|
||||
for y, row in enumerate(grid):
|
||||
if y:
|
||||
frags.append(("", "\n"))
|
||||
for top, bottom in row:
|
||||
tr, tg, tb, ta = top
|
||||
br, bg, bb, ba = bottom
|
||||
top_op = ta >= 32
|
||||
bot_op = ba >= 32
|
||||
if not top_op and not bot_op:
|
||||
frags.append(("", " "))
|
||||
elif top_op and bot_op:
|
||||
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x} bg:#{br:02x}{bg:02x}{bb:02x}", "▀"))
|
||||
elif top_op:
|
||||
# Upper half only — leave the lower half the terminal's bg
|
||||
# instead of painting it black (cleaner on light themes).
|
||||
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x}", "▀"))
|
||||
else:
|
||||
frags.append((f"fg:#{br:02x}{bg:02x}{bb:02x}", "▄"))
|
||||
return frags
|
||||
|
||||
def _pet_widget_height(self) -> int:
|
||||
"""Visible rows for the pet window — 0 collapses it when no pet shows."""
|
||||
with self._pet_lock:
|
||||
if not self._pet_enabled or self._pet_renderer is None:
|
||||
return 0
|
||||
grids = self._pet_frames_for(self._derive_pet_state())
|
||||
if not grids or not grids[0]:
|
||||
return 0
|
||||
return len(grids[0])
|
||||
|
||||
def _pet_anim_loop(self) -> None:
|
||||
"""Advance the frame + invalidate on a timer while a pet is enabled."""
|
||||
while self._pet_anim_running:
|
||||
time.sleep(self._PET_FRAME_INTERVAL)
|
||||
now = time.monotonic()
|
||||
if now - self._pet_cfg_checked >= self._PET_CFG_INTERVAL:
|
||||
self._pet_cfg_checked = now
|
||||
self._pet_resolve_config()
|
||||
if not self._pet_enabled:
|
||||
continue
|
||||
with self._pet_lock:
|
||||
self._pet_frame_idx += 1
|
||||
app = getattr(self, "_app", None)
|
||||
if app is not None:
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _pet_start_anim(self) -> None:
|
||||
if self._pet_anim_running:
|
||||
return
|
||||
self._pet_resolve_config()
|
||||
self._pet_anim_running = True
|
||||
self._pet_anim_thread = threading.Thread(target=self._pet_anim_loop, daemon=True)
|
||||
self._pet_anim_thread.start()
|
||||
|
||||
def _pet_stop_anim(self) -> None:
|
||||
self._pet_anim_running = False
|
||||
thread = self._pet_anim_thread
|
||||
if thread is not None:
|
||||
thread.join(timeout=0.3)
|
||||
self._pet_anim_thread = None
|
||||
|
||||
def _voice_record_key_label(self) -> str:
|
||||
"""Return the configured voice push-to-talk key formatted for UI.
|
||||
|
||||
@@ -7434,6 +7665,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
elif canonical == "personality":
|
||||
# Use original case (handler lowercases the personality name itself)
|
||||
self._handle_personality_command(cmd_original)
|
||||
elif canonical == "pet":
|
||||
self._handle_pet_command(cmd_original)
|
||||
elif canonical == "retry":
|
||||
retry_msg = self.retry_last()
|
||||
if retry_msg and hasattr(self, '_pending_input'):
|
||||
@@ -8973,6 +9206,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
stacked line to scrollback on tool.completed so users can see the
|
||||
full history of tool calls (not just the current one in the spinner).
|
||||
"""
|
||||
# Feed the pet: tools mean "running" (not reasoning); a failed tool
|
||||
# latches the turn so it ends on a sulk.
|
||||
if event_type == "tool.started":
|
||||
self._pet_reasoning = False
|
||||
elif event_type == "tool.completed" and kwargs.get("is_error"):
|
||||
self._pet_turn_error = True
|
||||
elif event_type and event_type.startswith("reasoning"):
|
||||
self._pet_reasoning = True
|
||||
|
||||
if event_type == "tool.completed":
|
||||
self._tool_start_time = 0.0
|
||||
# Print stacked scrollback line for "all" / "new" modes
|
||||
@@ -10925,6 +11167,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
spinner_widget,
|
||||
spacer,
|
||||
*self._get_extra_tui_widgets(),
|
||||
getattr(self, "_pet_widget", None),
|
||||
status_bar,
|
||||
input_rule_top,
|
||||
image_bar,
|
||||
@@ -12266,6 +12509,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
wrap_lines=True,
|
||||
)
|
||||
|
||||
# Petdex mascot — right-aligned half-block sprite above the prompt,
|
||||
# mirroring the TUI's PetPane. Collapses to height 0 when no pet is
|
||||
# enabled, so it's a no-op for everyone else. The _pet_anim_loop thread
|
||||
# advances frames + invalidates; align=RIGHT pins it to the edge.
|
||||
self._pet_widget = Window(
|
||||
content=FormattedTextControl(self._pet_fragments),
|
||||
height=self._pet_widget_height,
|
||||
align=WindowAlign.RIGHT,
|
||||
)
|
||||
|
||||
spacer = Window(
|
||||
content=FormattedTextControl(get_hint_text),
|
||||
height=get_hint_height,
|
||||
@@ -13036,6 +13289,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
|
||||
# Regular chat - run agent
|
||||
self._agent_running = True
|
||||
self._pet_turn_error = False
|
||||
self._pet_reasoning = False
|
||||
app.invalidate() # Refresh status line
|
||||
|
||||
try:
|
||||
@@ -13046,6 +13301,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._tool_start_time = 0.0
|
||||
self._pending_tool_info.clear()
|
||||
self._last_scrollback_tool = ""
|
||||
self._pet_reasoning = False
|
||||
self._pet_react_turn_end()
|
||||
|
||||
app.invalidate() # Refresh status line
|
||||
|
||||
@@ -13278,6 +13535,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# The app enables focus reporting + mouse tracking; record that
|
||||
# so _run_cleanup resets them on exit (#36823).
|
||||
_mark_tui_input_modes_active()
|
||||
# Drive the petdex mascot animation (no-op when no pet enabled).
|
||||
self._pet_start_anim()
|
||||
app.run()
|
||||
except (EOFError, KeyboardInterrupt, BrokenPipeError):
|
||||
pass
|
||||
@@ -13304,6 +13563,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
raise
|
||||
finally:
|
||||
self._should_exit = True
|
||||
self._pet_stop_anim()
|
||||
# Interrupt the agent immediately so its daemon thread stops making
|
||||
# API calls and exits promptly (agent_thread is daemon, so the
|
||||
# process will exit once the main thread finishes, but interrupting
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Ink TUI — diagnostic environment flags
|
||||
|
||||
Non-secret behavioral knobs for the Ink engine (`ui-tui/`). These are
|
||||
**environment overrides**, not `.env` secrets — set them in your shell for a
|
||||
session, or `export` them in your shell rc to make them sticky. They mirror the
|
||||
OpenTUI engine's flags (`docs/opentui-env-flags.md`) so a single switch covers
|
||||
both engines.
|
||||
|
||||
| Flag | Default | What it does |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_DIAGNOSTICS` | off | Master diagnostics switch. Turning it on enables the developer/profiling surface across the TUI — including the memory self-sampler below. One `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers **every** session you start, on **either** engine. |
|
||||
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`ui-tui/src/lib/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Defaults to the master switch; set `=1` / `=0` to force it on/off independently. |
|
||||
|
||||
## What the memory trace captures
|
||||
|
||||
Each Ink session, when sampling is enabled, appends one JSON line per second to
|
||||
its own file under `~/.hermes/logs/memwatch/`, keyed by boot time + pid:
|
||||
|
||||
```json
|
||||
{"t":1781514892,"rss_kb":92148,"heap_used_kb":7234,"external_kb":2378}
|
||||
```
|
||||
|
||||
- `t` — unix seconds.
|
||||
- `rss_kb` — resident set size (the number that matters for the native-RSS-gap
|
||||
story: rss climbing while heap stays flat is the #15141-class signal).
|
||||
- `heap_used_kb` — V8 heap in use.
|
||||
- `external_kb` — off-heap (buffers, native allocations).
|
||||
|
||||
**Ink emits no `mounted` / `peak_mounted` field.** Those are OpenTUI's
|
||||
windowing dev counters; Ink has no windowing, so it logs the rss/heap/external
|
||||
core only. `memwatch-report.mjs` treats `mounted` as optional, so Ink lines
|
||||
aggregate cleanly alongside OpenTUI's.
|
||||
|
||||
## Why this exists — cross-engine memory comparison
|
||||
|
||||
The filename scheme, directory, and line schema are **byte-compatible with
|
||||
OpenTUI's collector** (`ui-opentui/src/boundary/memlog.ts`). Both engines write
|
||||
to the same `~/.hermes/logs/memwatch/` directory, so one aggregator reads both:
|
||||
|
||||
```sh
|
||||
# enable on either/both engines (master switch covers both)
|
||||
export HERMES_TUI_DIAGNOSTICS=1
|
||||
HERMES_TUI_ENGINE=ink hermes --tui # Ink session → its own .jsonl
|
||||
HERMES_TUI_ENGINE=opentui hermes --tui # OpenTUI session → its own .jsonl
|
||||
|
||||
# fleet table across BOTH engines' sessions:
|
||||
cd ~/github/tui-bench && node memwatch-report.mjs
|
||||
```
|
||||
|
||||
This is what makes a true side-by-side **real-world** memory arc possible —
|
||||
cold floor → load → plateau/leak — instead of comparing OpenTUI dogfood traces
|
||||
against an Ink harness with no equivalent data.
|
||||
|
||||
## Cost & safety
|
||||
|
||||
- ~50 bytes/s when on; one `process.memoryUsage()` + one short append per
|
||||
second. The interval is **unref'd** — it never keeps the process alive.
|
||||
- 14-day retention: older traces are pruned (best-effort) at start.
|
||||
- **Every failure path disables the logger silently.** Diagnostics must never
|
||||
break the TUI — this is the one place the "errors propagate" rule is
|
||||
intentionally inverted, matching the OpenTUI collector.
|
||||
- Off by default: regular users write nothing.
|
||||
|
||||
## Getting a meaningful trace
|
||||
|
||||
A short scroll-through won't show growth. For a comparison against OpenTUI's
|
||||
4–5h sessions, drive a tool-heavy 2–3h Ink session as the floor (see
|
||||
`docs/plans/opentui-ink-asymmetry-note.md` for why the harness ≠ dogfood data).
|
||||
@@ -1,120 +0,0 @@
|
||||
# Handoff — OpenTUI memory + UX, continuing on the canonical branch
|
||||
|
||||
**You are continuing the Hermes OpenTUI engine work.** This is the base operating manual; the
|
||||
user (glitch) appends specific tasks on top. Read it, then read the repo docs it points to. It
|
||||
assumes NO prior transcript/memory.
|
||||
|
||||
## Where things are
|
||||
|
||||
- **Canonical branch: `feat/opentui-native-engine`** (the draft PR to main, #42922).
|
||||
`feat/opentui-memory-window` is a synonym at the *same tip* — they were consolidated. Treat
|
||||
native-engine as canonical; if you work from memory-window, periodically
|
||||
`git push origin HEAD:feat/opentui-native-engine` to keep them in sync, or just use native-engine.
|
||||
- The native engine source is **`ui-opentui/`**; the legacy Ink engine is `ui-tui/` (shipping
|
||||
default, untouched by this campaign). The Python gateway is `tui_gateway/`, launcher
|
||||
`hermes_cli/main.py`.
|
||||
- **The worktree is often the user's LIVE global `hermes`** (`~/.local/bin/hermes` symlinks into a
|
||||
worktree's `.venv`). Consequences: (1) NEVER leave the worktree in a half-merged/conflicted state
|
||||
— a new `hermes` session would fail to build; (2) after you land source changes, rebuild
|
||||
`dist/main.js` so the next session picks them up; (3) `hermes-stable` is the flip-back to the
|
||||
stock `~/.hermes/hermes-agent` install if you need to bypass the worktree.
|
||||
- Backups of pre-merge branch states exist as `backup/*` refs (recoverable via `git reset`).
|
||||
|
||||
## Runtime, build, gate (Node 26 — NOT Bun; the port is done)
|
||||
|
||||
```sh
|
||||
export PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH"
|
||||
cd ui-opentui && node scripts/build.mjs # → dist/main.js (esbuild + Solid/JSX)
|
||||
HERMES_TUI_MOUSE=1 node --experimental-ffi --no-warnings dist/main.js # launch; quit = double Ctrl+C
|
||||
cd ui-opentui && npm run check # THE GATE: prettier+eslint(typed)+vitest (~700). Judge by `echo $?`, never a piped tail.
|
||||
```
|
||||
|
||||
Never run bun here. Never run `hermes update` in the worktree (it flips the branch — recovery is
|
||||
painful). Never broad-pkill tui_gateway (other live sessions). Host RAM ~15GB, often <5GB free —
|
||||
run benches SEQUENTIALLY (the harness already wraps SUTs in `systemd-run … MemoryMax=2G`).
|
||||
|
||||
## The docs that are the source of truth (read, and KEEP UPDATED as you change things)
|
||||
|
||||
- `docs/opentui-memory-story.md` — ELI5 of the whole memory architecture (primitives + every decision).
|
||||
- `docs/plans/opentui-transcript-windowing.md` — windowing design (S1 spacers, S2 append-time), the
|
||||
`correctionIsLegal` zero-jank law, pre-registered gates, SHIPPED status + S3 backlog.
|
||||
- `docs/opentui-env-flags.md` — the consolidated env-flag ledger (master switch / user / dev / plumbing).
|
||||
- `docs/opentui-upstream-alignment.md` — forkless invariant, `boundary/` shim ledger, the per-release
|
||||
OpenTUI upgrade playbook (native-yoga is coming upstream — re-tune windowing margins when it lands).
|
||||
- the bench suite (cells, harness, live-attach, memwatch) now lives in its own
|
||||
repo: **tui-bench** (`github.com/NousResearch/tui-bench`); see its `README.md`.
|
||||
- `ui-opentui/README.md` — Node 26 onboarding (fnm setup that doesn't disturb other projects).
|
||||
- `docs/plans/ink-memory-adversarial-review.md` — Ink's memory weaknesses (F1–F10, the turnabout).
|
||||
- `docs/plans/gateway-death-forensics.md`, `docs/plans/workorder-2026-06-11-results.md`,
|
||||
`docs/plans/rebase-from-main-spec.md` — forensics, the merge-bar verdict, the rebase plan.
|
||||
|
||||
## Workflow (this is how the last 60+ commits were produced with ~zero rework)
|
||||
|
||||
1. **Subagent-driven** (skill: `subagent-driven-development`): one implementer per task with a TIGHT
|
||||
file fence ("you own exactly these files; `git diff --cached --stat` before commit, abort on
|
||||
out-of-fence"), a mandatory `opentui` skill read FIRST for any renderable work, and a gate judged
|
||||
by exit code. Verify the self-report YOURSELF (re-run the gate, read the riskiest hunks, check the
|
||||
commit file-list) — a subagent "✅ done" is a claim, not a fact.
|
||||
2. **Adversarial review** after a task: a fresh read-only reviewer (Explore-type) with NAMED attack
|
||||
surfaces. Then ADJUDICATE in code — reviewers over-flag; ~half of "blockers" don't survive a read.
|
||||
3. **Parallel implementers are safe ONLY with disjoint file fences.** Read-only recon agents
|
||||
parallelize freely.
|
||||
4. **Live smoke catches what headless can't** — tmux + the `tmux-pane-screenshot` skill for real
|
||||
colored frames. The demo: `node scripts/build.mjs scripts/demo.tsx .demo` then
|
||||
`DEMO_TOTAL=2000 … node --experimental-ffi --no-warnings .demo/demo.js`.
|
||||
5. Commit format `opentui(v6): …`, **NO attribution lines**. The user's standing instruction is
|
||||
"commit + push as you land things" — honor it; otherwise don't push without asking. Edit large
|
||||
load-bearing files (the Python launcher, `store.ts`) DIRECTLY, never via subagent.
|
||||
|
||||
## Dogfooding (the user works on this FROM the hermes TUI)
|
||||
|
||||
`export HERMES_TUI_DIAGNOSTICS=1` in the shell rc turns on, for every session: the `/mem` +
|
||||
`/heapdump` slash commands, window-stats, and **fleet memory self-logging** to
|
||||
`~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Aggregate all sessions with
|
||||
`node memwatch-report.mjs` from the **tui-bench** repo
|
||||
(`github.com/NousResearch/tui-bench`) (per-session baseline/peak/slope + SLOPE/PEAK/MOUNTED anomaly
|
||||
flags). Chase a flagged session with tui-bench's `live-attach.sh <pid> --heap`. The discipline: live
|
||||
anomaly → encode as a bench cell → fix → validate against live sessions again.
|
||||
|
||||
## Current state (2026-06) + the ranked backlog
|
||||
|
||||
Windowing SHIPPED: 2k-msg peak ~300MB (was 686; Ink 234), scroll p99 6ms, cap restored 1000→3000,
|
||||
determinism digest unchanged, peak mounted ~31 rows. Live sessions peak <200MB. The transcript is no
|
||||
longer the biggest lever — the ~160MB floor is ≈104MB Node+OpenTUI runtime + **≈55MB tool/skill
|
||||
catalogs hydrated at boot**. Ranked next levers:
|
||||
|
||||
1. **W3 — 1GB V8 heap default** (small, ~free): set the unconstrained default in
|
||||
`_resolve_tui_heap_mb`; both engines are Node now so both inherit it. Ink half = separate gated
|
||||
commit (shipping engine). Measured −90MB at bench scale.
|
||||
2. **cg_peak harness fix** (small): the cgroup `memory.peak` field is polluted (shared across runs) —
|
||||
reset/scope it before quoting tui-bench's `report.html` again. Trust `vmhwm_kb` + `samples[].rss_kb`.
|
||||
3. **New bench cells** (before W1, as its baselines): `resume-1900` (real p99 shape: time-to-first-
|
||||
paint + post-hydration RSS) and `10MB-tool-output` (the F1 byte-unbounded class). Run BOTH engines.
|
||||
4. **Catalog lazy-load** (new, promoted by live data): don't hydrate 1,185 tools at boot — fetch on
|
||||
picker-open. Attacks the ≈55MB floor; pays on EVERY session (median is 20 msgs). Likely cheaper
|
||||
than W1.
|
||||
5. **W1 thin renderer** (structural, biggest): bodies live in the gateway (SQLite); TUI keeps ~300B
|
||||
stubs + fetches bodies for the window only. Design the gateway windowed-read RPC FIRST. WATCH: `/copy`
|
||||
and the ⧉ block-copy read store parts — they need a fetch-on-demand fallback or W1 ships a copy regression.
|
||||
6. **Standing**: when native-yoga OpenTUI ships, run the upgrade playbook (re-bench, re-tune margins,
|
||||
audit the shim ledger). Three questions to relay to the OpenTUI maintainer are in the alignment doc.
|
||||
|
||||
## What NOT to do
|
||||
- Don't copy opencode's 100-msg store cap (user's p90 session is 182 msgs — it would truncate normal use).
|
||||
- Don't reintroduce estimate-correction scroll jank (the user explicitly vetoed it; `correctionIsLegal` forbids it).
|
||||
- Don't cite the obsolete "~210MB bun renderer / +120MB" memory figures — pre-port, pre-windowing, wrong.
|
||||
- Don't push/PR without the standing OK; don't commit `.plans/` scratch unless asked.
|
||||
|
||||
## Suggested skills
|
||||
(All available from the Hermes TUI agent too — this is the dogfooding surface. Curated to the load-bearing set, not the full ~40-skill catalog.)
|
||||
- `opentui-tui-engineering` — the workflow/architecture/pitfalls layer for `ui-opentui/` (just updated).
|
||||
- `hermes-tui-architecture` — the Hermes-specific TUI facts (launch pipeline, both engines; just updated).
|
||||
- `opentui` — the offline renderable-API doc set; mandatory `skill_view` before any view/renderable code.
|
||||
- `subagent-driven-development` — the process spine for parallel/heavy work.
|
||||
- `tmux-pane-screenshot` — real colored PNG of a tmux pane for visual verification (ported
|
||||
into hermes skills 2026-06-13). Use: `bash ~/.hermes/skills/software-development/
|
||||
tmux-pane-screenshot/scripts/tshot.sh <session:win.pane> out.png 2`, then Read the PNG.
|
||||
`freeze` (~/go/bin) + the resvg rasterizer are shared/system-wide — works as-is.
|
||||
- `effect-ts` — for the Effect-at-boundary entry/lifecycle code.
|
||||
- `superpowers:brainstorming` — before committing to a memory-architecture design (e.g. W1's store split).
|
||||
- `systematic-debugging` — if a gate fails; root-cause before patching.
|
||||
@@ -1,81 +0,0 @@
|
||||
# OpenTUI env flags — the consolidated ledger
|
||||
|
||||
Every environment variable the OpenTUI TUI reads (grep-verified 2026-06-12),
|
||||
classified by who should ever touch it. The design rule shipped with this doc:
|
||||
**regular users see zero diagnostic surface by default; one master switch
|
||||
(`HERMES_TUI_DIAGNOSTICS=1`) turns all of it on when needed.**
|
||||
|
||||
## 1. The master switch
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_DIAGNOSTICS` | **off** | Enables the diagnostic slash commands (`/mem`, `/heapdump`). While off they're hidden from `/help` (client-side filter) and invoking them prints the enable hint rather than executing. They never appear in slash *completion* in either state — completion is gateway-driven and these are client-only commands the gateway doesn't know (an adversarial review confirmed there's no bypass path; if a SERVER command named `mem`/`heapdump` is ever added it must be gated gateway-side too — the client gate would shadow but not hide it). Also flips the *default* of `HERMES_TUI_WINDOW_STATS` to on. Not a secret — support flows are "relaunch with `HERMES_TUI_DIAGNOSTICS=1`". |
|
||||
|
||||
## 2. User-facing configuration (fine to document publicly)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_ENGINE` | auto (`opentui` if Node≥26.3 + built, else `ink`) | Engine pick; also `display.tui_engine` in config.yaml. |
|
||||
| `HERMES_TUI_MOUSE` / `HERMES_TUI_MOUSE_TRACKING` / `HERMES_TUI_DISABLE_MOUSE` | on | Mouse support (wheel scroll, selection, click-to-expand). **Defers to Ink's env surface (`logic/env.ts` `resolveMouseEnabled`):** precedence is `HERMES_TUI_MOUSE_TRACKING` (toggle, force knob) > `HERMES_TUI_DISABLE_MOUSE=1` (legacy kill switch) > `HERMES_TUI_MOUSE` (OpenTUI-native alias, kept — also what the launcher sets) > default on. OpenTUI's renderer mouse is a single boolean, so Ink's granular off\|wheel\|buttons\|all collapses to on/off (the granular mode lives in `display.mouse_tracking` config). |
|
||||
| `HERMES_TUI_SCROLL_SPEED` (alias `CLAUDE_CODE_SCROLL_SPEED`) | native | Wheel-scroll speed multiplier (Ink parity). UNSET → OpenTUI's native scroll acceleration (untouched). A positive value (clamped to (0,20]) installs a constant-multiplier `ScrollAcceleration` on the transcript scrollbox (`view/transcript.tsx`). |
|
||||
| `HERMES_TUI_NO_CONFIRM` | off | Skip the destructive-action confirm step (`/clear`, `/new`) and run immediately (Ink parity, `NO_CONFIRM_DESTRUCTIVE`). Wired at the `confirm` seam (`entry/main.tsx`). |
|
||||
| `HERMES_TUI_MAX_MESSAGES` | ceiling | Scrollback rows kept in the TUI. Can LOWER the ceiling, never raise: 3000 with windowing, 1000 with windowing off (handle-table safety). |
|
||||
| `HERMES_TUI_TOOL_OUTPUT_LINES` | unlimited | Cap expanded tool-output lines (set a number to restore a cap). |
|
||||
| `HERMES_TUI_TOOL_OUTPUTS` | **on** | Keep rich tool-call OUTPUTS (full result body + raw result/args dicts). `=off` drops both the RENDER and the STORE of those bodies (Ink parity: only a one-line context preview + name/duration/error/diff survive) — the memory lever for the OpenTUI-vs-Ink retention asymmetry, and what the bench launches OpenTUI with for the fair engine-overhead comparison (W3). Diffs (file-edit) are KEPT either way. |
|
||||
| `HERMES_TUI_HEAP_MB` | cgroup-aware (default 8192) | V8 `--max-old-space-size` (MB) for BOTH engines. Highest precedence (then `display.tui_heap_mb` config, then the cgroup-75% fallback). Set it LOW for a low-mem session (still cgroup-clamped on top so it never exceeds the container); raise it to lift the ceiling. The low-mem opt-in signal that also arms `HERMES_TUI_PROACTIVE_GC` (W1). |
|
||||
| `HERMES_TUI_PROACTIVE_GC` | = low-`HERMES_TUI_HEAP_MB` (≤4096) | Idle-gated `global.gc()` for the low-mem path. Defaults ON only when a low heap cap is set (so the knobs compose); `=on`/`=off` forces it. Needs `--expose-gc` (the OpenTUI argv now carries it). Never runs mid-stream; tightens cadence above 400MB RSS but stays idle-gated. OpenTUI-only — Ink never GCs proactively (W2). |
|
||||
| `HERMES_TUI_COMPOSER_ROWS` | default rows | Composer height. |
|
||||
|
||||
## 3. Escape hatches & tuning (dev-facing, individually settable)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_WINDOWING` | **on** | `0` = bit-exact pre-windowing renderer (every row mounts; cap clamps back to 1000). The A/B + regression escape hatch. |
|
||||
| `HERMES_TUI_WINDOW_IDLE_MS` | ~1000 | Idle-measure pulse cadence (the spacer-exactness march). Test knob. |
|
||||
| `HERMES_TUI_WINDOW_STATS` | = `HERMES_TUI_DIAGNOSTICS` | Exposes live/peak mounted-row counters (`globalThis.__hermesTuiWindowStats`) for tui-bench's live-attach reads. |
|
||||
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`boundary/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl` (rss/heap/external + mounted rows; 14-day retention). Fleet view: `node memwatch-report.mjs` from the tui-bench repo (`github.com/NousResearch/tui-bench`). The "monitor all my sessions" answer: one `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers every session. |
|
||||
| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. |
|
||||
| `HERMES_HEAPDUMP_ON_START` | off | Write one V8 heap snapshot at boot (Ink parity). A deliberate baseline-capture escape hatch that BYPASSES the diagnostics master switch; lands at `$HERMES_HOME/logs/opentui-heap-<ts>.heapsnapshot` and echoes the path as a system line (`entry/main.tsx`). |
|
||||
| `HERMES_TUI_NOTIFY` | on | Desktop-notification kill switch (`=0`/`false`/`off` silences the "waiting on you" pings). The ping itself goes through the renderer's native `triggerNotification` (protocol detection + tmux/Zellij wrapping); the window title is not gated by this. |
|
||||
|
||||
## 4. Internal plumbing (set by the launcher/tui-bench/tests — humans never set these)
|
||||
|
||||
| var | set by | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). |
|
||||
| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. |
|
||||
| `HERMES_TUI_RESUME`, `HERMES_TUI_QUERY`, `HERMES_TUI_PROMPT`, `HERMES_TUI_IMAGE`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot; seeded prompt (`--tui "prompt"`: launcher sets `HERMES_TUI_QUERY`, the engine reads QUERY > the `HERMES_TUI_PROMPT` alias > a bare argv tail — `logic/env.ts` `startupPrompt`); seeded image PATH (`--image`: `HERMES_TUI_IMAGE`, `image.attach`ed before the prompt — `startupImage`, attach in `postSessionSetup`); fake-mode. |
|
||||
| `HERMES_AUTO_HEAPDUMP*` (`_COOLDOWN_MS`/`_MAX_BYTES`), `HERMES_HEAPDUMP_DIR`, `HERMES_HEAPDUMP_MAX_BYTES` | — | **NOT read by the OpenTUI engine (deliberate).** The engine ports Ink's #34095 silent-death early-WARNING (a transcript system line, `boundary/memoryMonitor.ts`) but NOT the auto heap-SNAPSHOT capture — the always-on memlog NDJSON trace is the diagnosis path, and its rss-vs-heap divergence is the better diagnostic for the native-RSS leak class (#15141) a V8 snapshot captures poorly. So the #41948 disk-fill safety set (gate/cooldown/byte-cap/dir) has no consumer here. `HERMES_HEAPDUMP_ON_START` (manual one-shot, §3) is the only heapdump knob the engine honors. |
|
||||
| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. |
|
||||
| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. |
|
||||
|
||||
## 5. Ink flags NOT ported — handled natively or out of scope
|
||||
|
||||
These exist on the legacy Ink TUI (`ui-tui/`) and are deliberately **not** read
|
||||
by the OpenTUI engine. Documented so a missing flag reads as a decision, not a gap.
|
||||
|
||||
| Ink flag | why not ported |
|
||||
|---|---|
|
||||
| `HERMES_TUI_TRUECOLOR` | OpenTUI core does COLORTERM/truecolor detection natively — the Ink force-truecolor hack is a fork workaround we shed. |
|
||||
| `HERMES_TUI_FORCE_OSC52` | OpenTUI core owns OSC52 clipboard as a primitive; no fallback hint needed. |
|
||||
| `HERMES_TUI_INLINE` / `HERMES_TUI_TERMUX_MODE` / `HERMES_TUI_TERMUX_FAST_ECHO` | Termux/primary-buffer accommodations. OpenTUI's native FFI floor (Node ≥26.3 + `--experimental-ffi`) is absent on Termux, so those sessions stay on **Ink** — these are correctly N/A for the OpenTUI engine. |
|
||||
| `HERMES_TUI_FPS` | Ink FPS overlay; the OpenTUI equivalent is the diag/window-stats surface (`HERMES_TUI_WINDOW_STATS`). Not parity-critical. |
|
||||
| `HERMES_DEV_CREDITS` / `HERMES_DEV_PERF*` | Dev-only throwaway scaffolding (live-spend readout, perf logging) — not user parity. |
|
||||
| `HERMES_BIN` / `HERMES_TUI_GATEWAY_URL` / `HERMES_TUI_SIDECAR_URL` | External-CLI / remote-gateway-URL overrides. OpenTUI spawns its gateway via the Effect boundary (`liveGateway.ts`) and does not shell out to `hermes` or take an external gateway URL. |
|
||||
| `HERMES_VOICE` | Voice mode is tracked on the OpenTUI parity backlog separately, not here. |
|
||||
|
||||
## How the pieces compose (the support script)
|
||||
|
||||
- Regular user, normal day: zero flags, zero diagnostic commands visible.
|
||||
- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes` → `/mem`
|
||||
for the live numbers, `/heapdump` for a snapshot to attach, window stats
|
||||
exposed for tui-bench's `live-attach.sh <pid>` to read.
|
||||
- Developer profiling: same master switch + the individual knobs
|
||||
(`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed.
|
||||
- Anything in section 4 appearing in a user-facing doc is a bug.
|
||||
|
||||
Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`),
|
||||
`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion
|
||||
filtering), `view/transcript.tsx` (stats default). Tests:
|
||||
`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands
|
||||
themselves, gate enabled suite-wide).
|
||||
@@ -1,207 +0,0 @@
|
||||
# How the OpenTUI transcript got from 686MB to ~300MB — the full story
|
||||
|
||||
*For: glitch. Branch: `feat/opentui-memory-window`. Everything here is measured,
|
||||
not vibes; every number has a result JSON in the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`).*
|
||||
|
||||
---
|
||||
|
||||
## 1. The cast of characters (the primitives, bottom-up)
|
||||
|
||||
To understand where the memory went, you need to know who's holding it. Six
|
||||
layers, from the screen up:
|
||||
|
||||
**The terminal grid.** Your terminal is a spreadsheet of character cells.
|
||||
Nobody pays per-message here — tmux holds ~5MB flat no matter how long the
|
||||
session is (we measured). The terminal is never the problem.
|
||||
|
||||
**The OpenTUI native renderer (Zig).** A compiled library that owns the
|
||||
"frame buffer" — the grid of cells about to be painted. Every piece of text the
|
||||
TUI shows lives in a native **TextBuffer** (the characters + their colors),
|
||||
viewed through a **TextBufferView**, styled by a **SyntaxStyle**. Each of those
|
||||
is a **native handle** — a ticket into one global table that has only **65,535
|
||||
slots, total, ever** (16-bit indices — like a coat check with 65k hooks).
|
||||
Destroying a renderable returns its tickets, so the constraint is not "how much
|
||||
have you ever created" but **"how much is alive right now."**
|
||||
|
||||
**Renderables.** OpenTUI's UI objects — `<text>`, `<box>`, `<markdown>`,
|
||||
`<code>`, `<scrollbox>`. One transcript row (a message with its tool calls,
|
||||
markdown, code blocks, copy chips) is a *tree* of these: **~16 text renderables
|
||||
≈ 47 native handles ≈ ~250–340KB of RSS, per row.** This is the number that
|
||||
drives everything. 1,400 mounted rows × 47 handles = table full = the crash we
|
||||
root-caused last week.
|
||||
|
||||
**Yoga (the layout engine, WASM).** Every renderable also has a Yoga node —
|
||||
Yoga is the flexbox calculator that decides where boxes go. OpenTUI ships it
|
||||
compiled to **WebAssembly**, and WASM has a brutal property: its memory can
|
||||
**grow but never shrink** back to the OS. So the peak number of
|
||||
*simultaneously-mounted* renderables sets a high-water mark you pay **forever**,
|
||||
even after everything is destroyed. (Fun fact from this week's forensics: we
|
||||
spent two days believing Ink had this disease. It doesn't — our Ink fork swapped
|
||||
Yoga-WASM for a plain TypeScript port at fork creation. **We** are the ones
|
||||
running layout in WASM. The accusation was true; we just had the defendant
|
||||
wrong.)
|
||||
|
||||
**Solid (the view framework).** Renders each store message into a row via
|
||||
`<For>`. The property we exploit: Solid mounts/unmounts *surgically* — remove a
|
||||
row from what the component returns and Solid destroys exactly that row's
|
||||
renderables (returning its handles and freeing its Yoga nodes), touching
|
||||
nothing else. No virtual-DOM diffing, no collateral re-renders.
|
||||
|
||||
**V8 (the JavaScript engine) + the store.** The store keeps every message as JS
|
||||
strings/objects. V8's garbage collector is *lazy by design*: with the default
|
||||
8GB ceiling we launch with, it sees no reason to clean up aggressively, so RSS
|
||||
includes a lot of "collectible but not yet collected" garbage. Cheap to fix,
|
||||
worth real MB (measured below).
|
||||
|
||||
**The scrollbox.** One detail that fooled everyone at some point:
|
||||
`viewportCulling` (on by default) skips *drawing* offscreen rows — but they stay
|
||||
fully **mounted**: handles held, Yoga nodes alive, memory paid. Culling saves
|
||||
paint time, not memory. That misunderstanding is half the reason the "rolling
|
||||
store cap" was expected to be enough, and wasn't.
|
||||
|
||||
## 2. Why it was 686MB
|
||||
|
||||
Simple arithmetic. The old TUI mounted **every message in the store** as a full
|
||||
renderable tree. 2,000 messages × ~16 renderables × (handles + Yoga nodes +
|
||||
text buffers + V8 objects) ≈ 670–690MB, growing ~300MB per 1,000 messages. And
|
||||
at ~1,400 rows the handle table filled: first a hard crash (exit 7), then —
|
||||
after our containment fix — survival with **unstyled text** past that point,
|
||||
plus a cap clamped from 3,000 rows down to 1,000 as the price of not crashing.
|
||||
|
||||
Ink, meanwhile, sat at ~234MB at the same workload, because Ink only ever
|
||||
mounts the rows near your viewport (~84–400 live nodes). Its memory is the
|
||||
*data* plus some caches — not the *view*.
|
||||
|
||||
## 3. The decisions, in order
|
||||
|
||||
### Decision 1: virtualize the view, don't starve the store
|
||||
|
||||
Two ways to cut view memory: keep fewer messages (opencode's answer — they keep
|
||||
100 and delete the rest from memory; transcript truth lives on their server), or
|
||||
keep all messages but only *materialize* the ones near the viewport. You vetoed
|
||||
the first (your p90 session is 182 messages — a 100-row store truncates normal
|
||||
sessions), so: **windowing**. Notably the OpenTUI devs confirmed this week that
|
||||
framework-level virtualization is the intended path — the engine doesn't ship
|
||||
it out of the box, and opencode never built it. We did.
|
||||
|
||||
### Decision 2: exact heights, recorded at unmount — never estimates in your face
|
||||
|
||||
This is the load-bearing idea, and it's where we beat Ink at its own game.
|
||||
|
||||
The hard problem of any virtualized list: an unmounted row still needs to
|
||||
occupy its correct *height*, or the scrollbar lies and content jumps. Ink
|
||||
solves it by **guessing** heights and correcting after measurement — those
|
||||
corrections are precisely the 83–101ms scroll stutters you hate. You explicitly
|
||||
vetoed "estimate-correction jank" as a model.
|
||||
|
||||
Our advantage: OpenTUI lays out with real, queryable heights. So when a row
|
||||
scrolls out of the window, we record its **exact laid-out height** (an
|
||||
`onSizeChange` hook fires inside layout, pre-paint) and replace the row with an
|
||||
empty `<box height={exactly-that}/>` — a **spacer**: one Yoga node, zero text
|
||||
buffers, zero native handles. Think of a bookshelf where books you're not
|
||||
reading are swapped for cardboard sleeves cut to *exactly* the book's
|
||||
thickness: the shelf never shifts, and you can't tell from across the room.
|
||||
|
||||
The window is your viewport ± one viewport of margin (plus hysteresis so it
|
||||
doesn't thrash at the edges). Scroll near a spacer and the real row remounts —
|
||||
at the recorded height, so nothing moves.
|
||||
|
||||
And one **law**, written into the code as `correctionIsLegal`: a spacer's
|
||||
height may only ever be corrected where you *cannot see it* — fully above the
|
||||
viewport (with the scroll position compensated in the same frame, so the world
|
||||
doesn't move) or fully below it. A correction that would shift visible content
|
||||
is forbidden, structurally. Jank isn't tuned down; it's outlawed.
|
||||
|
||||
### Decision 3 (the S2 insight): adjudicate on *append*, not just on scroll
|
||||
|
||||
S1 alone got 686 → 518MB. Why not more? Because of *when* windowing decided.
|
||||
S1 re-decided the window when you **scrolled**. But during a streaming burst —
|
||||
an agent turn dumping hundreds of rows — you don't scroll; rows arrive, each
|
||||
mounting fully, and only get demoted later. That transient pile-up is mostly
|
||||
invisible in steady-state numbers… except for Yoga-WASM, where **the transient
|
||||
peak is permanent** (memory never shrinks). The burst was quietly ratcheting
|
||||
the floor.
|
||||
|
||||
S2 makes the window recompute on **transcript growth**: while you're pinned at
|
||||
the bottom, the window anchors to the content *bottom*, so a row that falls
|
||||
more than a margin behind the live edge becomes a spacer the moment it's
|
||||
measured — not whenever you next scroll. Measured result: across a 1,500-row
|
||||
burst, the peak number of simultaneously-mounted rows is **31**.
|
||||
|
||||
Same trick for **resume**: opening a 2,000-message session used to mount all of
|
||||
it (transient peak again — paid forever). Now resume mounts only the bottom
|
||||
window; everything above starts as spacers using a line-count estimate, and an
|
||||
idle-time "measure march" quietly mounts ten rows at a time near the window
|
||||
edge, records their true heights, and swaps them back — all outside the
|
||||
viewport, all invisible by the law above.
|
||||
|
||||
### Decision 4: rows that must never be windowed
|
||||
|
||||
Windowing has to know what it's not allowed to touch:
|
||||
- **Streaming rows** — the native markdown renderer streams incrementally;
|
||||
unmounting mid-stream would restart it visibly.
|
||||
- **The bottom 30 rows** — the region you actually live in.
|
||||
- **Rows under a mouse selection** — the review caught that a lingering
|
||||
highlight originally froze windowing *forever* (memory regrowing silently).
|
||||
Fixed: only an active drag pauses swaps, and selected rows get pinned, so
|
||||
copy is byte-exact while everything else keeps windowing.
|
||||
|
||||
### Decision 5: give back the scrollback (cap 1,000 → 3,000)
|
||||
|
||||
The 1,000-row clamp existed only because mounted-rows == stored-rows and the
|
||||
handle table dies at ~1,400. With windowing, mounted ≈ 31 regardless of store
|
||||
size — so the cap went back to the originally-shipped 3,000. It's
|
||||
windowing-aware: the `HERMES_TUI_WINDOWING=0` escape hatch (which mounts
|
||||
everything again) keeps the safe 1,000.
|
||||
|
||||
### Decision 6 (measured, not yet shipped as default): right-size the V8 heap
|
||||
|
||||
Running the windowed TUI with a 512MB heap ceiling instead of 8GB forced V8 to
|
||||
actually collect: another −90MB with zero latency cost. That's queued as a
|
||||
launcher default change (~1GB), for both engines.
|
||||
|
||||
## 4. The scoreboard
|
||||
|
||||
At 2,000 messages (your real p99 session size — yes, we checked your DB:
|
||||
median session is 20 messages, p99 is 1,941):
|
||||
|
||||
| | peak memory | scroll p99 (slowest 1-in-100) |
|
||||
|---|---|---|
|
||||
| OpenTUI before | 686MB | 16ms |
|
||||
| + S1 windowing | 518MB | 16ms |
|
||||
| + S2 append/resume windowing | **300–375MB** | **6ms** |
|
||||
| Ink (reference) | 229–246MB | ~100ms |
|
||||
|
||||
At the **3,000-message stress** with the restored triple-size scrollback:
|
||||
**360MB, fully styled, scroll p99 8ms** — a workload that six days ago crashed
|
||||
the process, and three days ago survived only by dropping syntax colors.
|
||||
|
||||
Scroll got *faster* because there are simply fewer live renderables to walk.
|
||||
The determinism gate stayed **byte-identical** — the windowed TUI's settled
|
||||
frame is provably the same pixels as before. And the live smoke (2,000-message
|
||||
session: full sweep to the top, resize storm, back to bottom) returned a frame
|
||||
pixel-identical to boot, with deep history fully syntax-highlighted — something
|
||||
the pre-windowing TUI literally could not do.
|
||||
|
||||
## 5. What's honestly still open
|
||||
|
||||
- The remaining ~60–120MB over Ink is mostly the **store's JS strings** and
|
||||
process baseline — the view is no longer the problem. The structural fix is
|
||||
the **thin renderer** (W1): bodies live in the Python gateway (which already
|
||||
has them in SQLite); the TUI keeps ~300-byte stubs and fetches bodies only
|
||||
for the window. That also fixes the class of problem neither engine handles
|
||||
today: a single 10MB tool output.
|
||||
- Two accepted, documented limits: scrollbar-*jumping* deep into a freshly
|
||||
resumed session can land on estimate-height rows that snap to true height as
|
||||
they enter view (normal scrolling doesn't — the margin pre-measures; the idle
|
||||
march erodes the exposure over time), and a tool you expanded, scrolled far
|
||||
away from, then returned to will have re-collapsed (state is component-local;
|
||||
hoisting it to the store is queued).
|
||||
- Everything is behind `HERMES_TUI_WINDOWING` (default on, `0` = bit-exact old
|
||||
behavior) — a one-env escape hatch if anything feels off in real use.
|
||||
|
||||
*Where to verify: the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`; every number above), the design+gates doc
|
||||
`docs/plans/opentui-transcript-windowing.md`, tests in
|
||||
`ui-opentui/src/test/window.test.ts` and `transcriptWindow.test.tsx` (the
|
||||
zero-jank invariants are literal assertions: identical scrollHeight windowed
|
||||
vs not, byte-stable frames across corrections).*
|
||||
@@ -1,432 +0,0 @@
|
||||
# OpenTUI native engine — PR documentation
|
||||
|
||||
**Branch:** `feat/opentui-native-engine` · **Base:** `origin/main` (merged in; HEAD is at `~main`)
|
||||
**New engine root:** `ui-opentui/` (Node 26 + `@opentui/core` 0.4.1 + `@opentui/solid`, Effect at the boundary)
|
||||
**Legacy engine root:** `ui-tui/` (React + the `@hermes/ink` fork at `ui-tui/packages/hermes-ink/`)
|
||||
|
||||
> This is the canonical in-repo doc for the PR. The companion interactive HTML
|
||||
> write-up (`~/projects/opentui-perf-writeup/index.html`) is the case/benchmark
|
||||
> deep-dive; this doc is the reviewable text version + the four things review
|
||||
> actually needs: **(1) the LoC reduction math, (2) the measured perf deltas,
|
||||
> (3) the real UI divergence (with screenshots), (4) the non-core / kitchen-sink
|
||||
> change audit.**
|
||||
|
||||
This PR adds a from-scratch native terminal UI built on OpenTUI, intended to
|
||||
replace the React/Ink TUI **and the Ink fork we maintain alone**. It currently
|
||||
ships as a parallel engine (Ink untouched, auto-fallback), selected by
|
||||
`HERMES_TUI_ENGINE` env > `display.tui_engine` config > auto (OpenTUI when the
|
||||
host is Node ≥ 26.3 with the built bundle, else Ink). **100% parity with the Ink
|
||||
TUI is the bar.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Line-of-code reduction (the headline maintenance win)
|
||||
|
||||
All counts are **git-tracked files only** (respects `.gitignore`; `dist/` and
|
||||
`node_modules/` are untracked and excluded). Measured live on this branch at
|
||||
`~HEAD`. "Code" = `.ts/.tsx/.js/.jsx` only; "total" includes config/json/md.
|
||||
|
||||
### What gets *removed* when Ink is retired
|
||||
|
||||
| Area | Files | Total lines | Code lines (ts/tsx/js) | Non-blank code |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `ui-tui/src/` — Ink **consumer app** (our React/Ink view code) | 204 | 40,422 | 40,422 | 33,550 |
|
||||
| `ui-tui/packages/hermes-ink/` — **the fork** (`@hermes/ink`) | 148 | 28,167 | 28,113 | 23,718 |
|
||||
| **`ui-tui/` whole tree (tracked)** | **362** | **69,320** | **68,831** | **57,545** |
|
||||
|
||||
The `ui-tui/` whole-tree number (69,320) also folds in a handful of build
|
||||
scripts, `.prettierrc`, `package.json`, etc. The two rows above it are the
|
||||
load-bearing split:
|
||||
|
||||
- **The fork alone is 28,167 LOC across 148 files** — code we own and can never
|
||||
sync from upstream. Upstream Ink v6.8.0 `src/` is ~7,259 LOC, so the fork's
|
||||
renderer core is **~3.2× the size of stock Ink**. (Cross-checked against the
|
||||
HTML write-up's `ink-fork-analysis.json`: 28,111 LOC / 148 files — the 56-line
|
||||
delta is a single tracked JSON the file-level count includes.)
|
||||
- **The consumer app is another 40,422 LOC** — React components/hooks that only
|
||||
exist to drive Ink.
|
||||
|
||||
### What gets *added*
|
||||
|
||||
| Area | Files | Total lines | Code lines | Non-blank code |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `ui-opentui/src/` — new engine (app code **+ its own tests**) | 153 | 28,763 | 28,763 | 26,495 |
|
||||
| ↳ non-test (app code only) | 97 | 16,628 | 16,628 | 15,450 |
|
||||
| ↳ tests (`src/test/`) | 56 | 12,135 | 12,135 | 11,045 |
|
||||
| Tree-sitter grammars (`python`…`toml`) | 0 | 0 | 0 | 0 |
|
||||
| **`ui-opentui/` whole tree (tracked)** | **~170** | **~34,800** | **29,614** | **27,283** |
|
||||
|
||||
> Tree-sitter grammars carry **zero repo lines**: the engine declares the 10
|
||||
> extra grammars as remote URLs (`src/boundary/parsers.manifest.json`) and
|
||||
> OpenTUI fetches+caches each `.wasm`/`.scm` on first use into
|
||||
> `~/.hermes/cache/opentui-parsers/` (à la opencode, which vendors none). An
|
||||
> earlier revision vendored them as 37,302 checked-in binary lines (10 `.wasm` +
|
||||
> 10 `.scm`); that's gone — code lines and total lines now move together.
|
||||
|
||||
### The net reduction (code lines, the honest comparison)
|
||||
|
||||
| Comparison | Removed (ts/tsx/js) | Added (ts/tsx/js) | Net change |
|
||||
|---|---:|---:|---:|
|
||||
| **Incl. fork** — retire all of `ui-tui/` vs add `ui-opentui/src` | −68,831 | +28,763 | **−40,068 LOC (−58%)** |
|
||||
| **Incl. fork, app-vs-app** (exclude both test suites) | −56,463¹ | +16,628 | **−39,835 LOC (−71%)** |
|
||||
| **Excl. fork** — only the Ink *consumer app* vs new engine | −40,422 | +28,763 | **−11,659 LOC (−29%)** |
|
||||
| **The fork in isolation** (the unsyncable liability we shed) | −28,113 | — | **−28,113 code lines deleted outright (28,167 incl. its 1 config file)** |
|
||||
|
||||
¹ `ui-tui/src` non-test = 28,350 LOC + fork (≈ all 28,113 code lines are non-test;
|
||||
it carries only ~54 config lines) = 56,463. (`ui-tui/src` carries 80 test files /
|
||||
12,072 LOC; the new engine carries 56 test files / 12,135 LOC.)
|
||||
|
||||
**Read it this way:**
|
||||
|
||||
- **The cleanest single number: ~−40k code lines net** (retire all of `ui-tui/`,
|
||||
add `ui-opentui/src`). That is a **~58% reduction in the TUI's
|
||||
hand-maintained surface**, and it *includes* the new engine's full 56-file test
|
||||
suite.
|
||||
- **The most important number is the fork: −28,167 LOC of unsyncable engine
|
||||
code** disappears. That is the load-bearing maintenance win — it's not just
|
||||
fewer lines, it's lines we are the *sole* maintainer of (own reconciler, ANSI
|
||||
parser, scrollbox, selection/OSC52, hand-rolled memory eviction, Yoga binding).
|
||||
- **Even excluding the fork** — i.e. if you imagine upstream Ink were free — the
|
||||
app rewrite is still a net reduction (−11,659 LOC) because the new engine
|
||||
mounts OpenTUI built-ins instead of hand-building components.
|
||||
|
||||
### Caveat on the comparison (keep it honest for review)
|
||||
|
||||
- These are **whole-tree retirements vs a single source dir add.** If/when Ink is
|
||||
deleted, the `ui-tui/` `package.json`, lockfile, and build scripts go too; the
|
||||
table counts `ui-tui/src` + the fork as the apples-to-apples "hand-maintained
|
||||
TS" figure.
|
||||
- **Tree-sitter grammars are NOT vendored.** The 10 extra grammars are declared
|
||||
as remote URLs (`src/boundary/parsers.manifest.json`); OpenTUI fetches each
|
||||
`.wasm`/`.scm` on first use of a language and caches it under
|
||||
`~/.hermes/cache/opentui-parsers/` (profile-aware, set via
|
||||
`HERMES_TUI_PARSER_CACHE` by the launcher). Registration does **zero** network;
|
||||
the fetch is lazy and off the boot critical path, and an unreachable
|
||||
GitHub/air-gapped env degrades that language to plain text — never a throw. This
|
||||
replaces an earlier revision that vendored 37k binary lines, so the repo no
|
||||
longer grows on disk for syntax highlighting. (Trade-off: first-use-per-language
|
||||
needs network to `github.com`/`raw.githubusercontent.com`; pre-seed the cache in
|
||||
a Docker build if you need offline highlighting.)
|
||||
- Python/backend LoC is **not** part of this reduction: `tui_gateway/` (~12k LOC)
|
||||
is **shared by both engines** and stays. See §4.
|
||||
|
||||
---
|
||||
|
||||
## 2. Performance (CPU / latency / memory)
|
||||
|
||||
Measured with the `tui-bench` harness driving **both engines on a real PTY
|
||||
120×40**, fake gateway feeding deterministic events, `/proc`-sampled identically,
|
||||
each SUT under `systemd-run --scope -p MemoryMax=2G -p MemorySwapMax=0`,
|
||||
sequential with a load-gate + 10s cooldown. Determinism gate **GREEN**, 71 result
|
||||
files, 0 cell errors, 3 reps/cell, `@opentui/core` 0.4.1 native-yoga
|
||||
(`libopentui.so`, no `yoga.wasm`). Every number traces to a `summary.<field>` in
|
||||
a result dir. Source: `~/projects/opentui-html/bench-numbers.json` (frozen
|
||||
2026-06-14, build under test `1ddf7a102` + WIP).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Winner | Margin | Source cell |
|
||||
|---|---|---|---|
|
||||
| Streaming frame rate | **OpenTUI** | **~3×** (43 vs 14 fps) | `cpu800.frame_pacing` |
|
||||
| Streaming smoothness (interframe p95) | **OpenTUI** | **40ms vs ~220ms** (no ¼-second stalls) | `cpu800.frame_pacing` |
|
||||
| Scroll CPU | **OpenTUI** | **~2.7× cheaper** (134–155 vs 403–416 ticks) | `scroll3000.scroll.cpu_ticks` |
|
||||
| Cold-start floor | **OpenTUI** | ~97–103 vs ~107–109 MB | `startup.vmhwm_kb` |
|
||||
| Session-create latency | **OpenTUI** | ~151–177 vs ~204–229 ms | `startup.session_create_ms` |
|
||||
| First-byte paint | Ink | ~93 vs ~122 ms | `startup.first_byte_ms` |
|
||||
| Memory @ small/typical | Ink | OpenTUI +30–50 MB | `mem50/100/300.vmhwm` |
|
||||
| Memory @ heavy tool output | **OpenTUI** | **crossover** (258–265 vs 280–290 MB) | `results-fat-mem-*` |
|
||||
| Layout reflow latency | **Ink** | **~0ms vs ~13ms** (OpenTUI's one honest loss) | `resize3000.resize.reflow_ms` |
|
||||
|
||||
### The honest reading
|
||||
|
||||
- **OpenTUI wins everything you feel continuously** — frame rate (~3×), scroll
|
||||
CPU (~2.7×), and smoothness (no 200ms hitches; p95 40ms vs ~220ms). This is the
|
||||
lead. The single most user-perceptible difference is the stall-free stream.
|
||||
- **Memory: lead with smoothness, not raw RSS.** Ink is lighter at small/typical
|
||||
sizes (OpenTUI carries a ~102 MB irreducible Node+V8+`libopentui.so` floor, so
|
||||
it sits +30–50 MB above Ink there). But it **crosses over** under heavy tool
|
||||
output (mem300: 258–265 MB OpenTUI vs 280–290 MB Ink) because windowing beats
|
||||
Ink's mount-every-row. Real-world: 20 memwatch sessions show a flat ~108 MB
|
||||
floor and ~0 MB/h on long sessions (one 15h session, 0 MB/h; one 4.4h session
|
||||
plateaus flat at ~237 MB with mounted rows pinned at 33).
|
||||
- **The one outright loss is layout reflow** (~13ms p50 vs Ink's ~0ms; under a
|
||||
resize storm OpenTUI degrades to ~14fps/~197ms vs Ink ~26fps/~100ms). Heavier
|
||||
native renderables vs Ink's string nodes. This is a real, quantified
|
||||
optimization target — **not** a regression vs current behavior, and **not** the
|
||||
"halved 0.4.0→0.4.1" delta (we measured the absolute 12–15ms only; do not quote
|
||||
"halved" from this run).
|
||||
- **The memory fix is engine-agnostic** — a rolling display cap
|
||||
(`HERMES_TUI_MAX_MESSAGES=3000` default) that is display-only and never touches
|
||||
the model's context. Uncapped is a stress config, not real usage (10k msgs
|
||||
uncapped: 793 MB; capped sessions are flat MB/h).
|
||||
- **Gut-check vs upstream/opencode: no bugs.** Exactly one frame callback
|
||||
(early-exits cheaply), zero `writeToScrollback` for the transcript (one sticky
|
||||
`<scrollbox>` + reactive `<For>`), native `<markdown streaming>` byte-for-byte
|
||||
parity with live opencode, no reactive-read-outside-tracking-scope (the #1 Solid
|
||||
trap). Source: `docs/plans/opentui-gutcheck-verification.md`.
|
||||
|
||||
Full methodology + every cell: see the HTML write-up's benchmark sections and
|
||||
`docs/plans/opentui-endgame-benchmark-report.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI parity — and where the two engines genuinely diverge visually
|
||||
|
||||
100% *feature* parity is the bar (matrix in §6), but the two engines are **not**
|
||||
visually identical. The Ink TUI renders the transcript as a **box-drawing tree**;
|
||||
OpenTUI renders it **flat and marker-based**. This is a deliberate design
|
||||
divergence, captured in `ui-opentui/src/view/messageLine.tsx`:
|
||||
|
||||
> *"the view is a dark room and gold is the single lamp — it sits on the NEWEST
|
||||
> answer's `⚕` and the user's `❯`, nowhere else (older assistant glyphs demote to
|
||||
> grey: they merely happened)."*
|
||||
|
||||
Real screenshots (saved under `docs/research/opentui-screenshots/`), captured live
|
||||
on a real PTY 120×40 via the `tmux-pane-screenshot` workflow — **same session
|
||||
resumed in both engines** where possible.
|
||||
|
||||
### Legacy Ink — `docs/research/opentui-screenshots/ink-transcript.png`
|
||||
|
||||

|
||||
|
||||
- **Box-drawing tree layout.** Each turn is a nested structure: `└─ Response`,
|
||||
`└─ ▾ Tool calls (1)`, ` └─ ● Terminal("…")` — explicit corner rails and
|
||||
disclosure triangles.
|
||||
- **`┊` dotted quote-bar** prefixes assistant prose.
|
||||
- **Tool calls collapse by default** behind a `▾ Tool calls (N)` disclosure,
|
||||
nested one rail deeper.
|
||||
- **Whole assistant message tinted gold/amber** (body text is colored, not just
|
||||
the marker).
|
||||
- Right-edge scrollbar: thin `│` track + `┃`/orange thumb.
|
||||
- Status bar: `─ ready │ opus 4.8 fast high │ 0/1m │ [░░░░░░] 0% │ 25s │ voice off │ 1 session ─ ~`
|
||||
— leading dash, pipe-delimited fields, trailing `~`.
|
||||
- **No top header bar.**
|
||||
|
||||
### New OpenTUI — `docs/research/opentui-screenshots/opentui-transcript.png` (+ `opentui-toolcall.png`)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
- **Flat, marker-based layout.** No tree rails. Assistant = `⚕` (caduceus, gold
|
||||
only on the newest answer), user = `❯` (gold chevron + gold text). Older
|
||||
assistant glyphs demote to grey.
|
||||
- **Neutral body text.** Gold is reserved for markers and inline-code accents;
|
||||
prose is grey/white (the "single lamp" rule), so the screen reads calmer than
|
||||
Ink's all-amber blocks.
|
||||
- **Tool calls render inline, expanded, on one header line:**
|
||||
`⚕ ▶ delegate_task Run the shell command `…` (/agents to monitor) · 41s (11 lines)`
|
||||
— marker, `▶` collapse triangle, bold tool name, grey arg preview, hint,
|
||||
`· duration`, `(N lines)` — and the result flows flat directly below (no nesting
|
||||
rail). Per-tool renderers exist (`view/tools/registry.tsx`) — bash/file+diff/
|
||||
read/search/skill/clarify/todo each render differently, not a uniform dump.
|
||||
- **Per-block `⧉ copy` affordance** on a quiet footer line under every settled
|
||||
assistant block and user prompt (click → copies that block's source).
|
||||
- **Top header bar:** `⚕ Hermes Agent · opentui · ready` + a gold horizontal rule
|
||||
(Ink has none).
|
||||
- Status bar (real backend): `● claude-fable-5 │ [▒▒▒] 4% │ …/lively-thrush/hermes-agent (feat/opentui-native-engine)`
|
||||
— green status dot, model, context/token bar, **right-pinned cwd + branch**.
|
||||
|
||||
### Divergence summary table
|
||||
|
||||
| Aspect | Ink (legacy) | OpenTUI (new) |
|
||||
|---|---|---|
|
||||
| Transcript structure | Box-drawing **tree** (`└─`, rails) | **Flat**, indented, marker-based |
|
||||
| Assistant marker | `└─ Response` rail + `┊` quote-bar | `⚕` caduceus glyph |
|
||||
| User marker | (rail) | `❯` gold chevron |
|
||||
| Assistant body color | Tinted gold/amber | Neutral grey/white (gold = accents only) |
|
||||
| Tool calls | Collapsed `▾ Tool calls (N)`, nested | Inline expanded header + flat result |
|
||||
| Per-tool rendering | Largely uniform | Dedicated renderers per tool |
|
||||
| Copy affordance | `/copy` command | `/copy` **+ per-block `⧉ copy`** |
|
||||
| Header bar | None | `⚕ Hermes Agent · opentui · ready` + rule |
|
||||
| Status bar | `─`/`│`-delimited, trailing `~` | dot + bars + right-pinned cwd/branch |
|
||||
|
||||
**For review:** the divergence is intentional (a design pass, not an accident),
|
||||
but it means "drop-in replacement" is true at the *feature* level, not the
|
||||
*pixel* level. A user switching engines will immediately notice the flatter,
|
||||
calmer transcript. Worth calling out explicitly so the swap isn't sold as
|
||||
visually invisible.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-core / kitchen-sink change audit (what review should scrutinize)
|
||||
|
||||
Full report: **`docs/research/opentui-noncore-change-audit.md`** (file-by-file,
|
||||
commit-by-commit, with `file:line` evidence). Summary below.
|
||||
|
||||
This PR's net footprint vs `origin/main` (two-dot diff = exactly this PR's adds,
|
||||
no main work re-included):
|
||||
|
||||
| Bucket | Files | Net diff |
|
||||
|---|---:|---:|
|
||||
| UI (`ui-opentui/`, the engine + tests) | 197 | +36,001 / −1 |
|
||||
| Docs | 8 | +1,164 / −0 |
|
||||
| **Other (the review-flag surface)** | **28** | **+3,218 / −204** |
|
||||
|
||||
The 28 "other" files are the only place this PR touches shared Hermes core. They
|
||||
classify as:
|
||||
|
||||
### ✅ CORE-OPENTUI-NECESSARY (the engine can't work without these; Ink path provably untouched)
|
||||
|
||||
- **`hermes_cli/main.py`** (+382/−5) — dual-engine launcher (engine resolution,
|
||||
Node 26 / fnm detection, `_make_opentui_argv`, heap override). Default falls
|
||||
back to Ink unless the host is OpenTUI-ready (`main.py:1685`); OpenTUI is
|
||||
dispatched *around* the Ink bootstrap, never through it (`main.py:1914-1922`).
|
||||
- **`scripts/install.sh`** (+78/−1) — `install_opentui` stage, **strictly
|
||||
best-effort** (every failure returns 0; falls back to Ink; Windows/Termux
|
||||
skipped). Ink install path unchanged.
|
||||
- **`Dockerfile`** (+21/−11) — Node 22→**26** bump (required by the `node:ffi`
|
||||
renderer) + `ui-opentui` build step. Opt-in; Ink build line preserved. **Caveat:
|
||||
the Node major bump affects the whole image (Ink + web + Playwright)** — the
|
||||
diff self-flags "verify the full image build on Node 26 in CI."
|
||||
- **`hermes_cli/_parser.py`** (+16/−2) — bare `--resume` → OpenTUI session picker;
|
||||
`--resume <id>` unchanged.
|
||||
- **`tui_gateway/server.py`** (+612/−40) — predominantly opt-in RPCs/fields the
|
||||
new engine calls (`session.peek`, `session.list` filters, `startup.catalog`,
|
||||
`diff_unified`, window-title, skin keys). Each is gated so **the Ink path is
|
||||
byte-for-byte unchanged** (`server.py:3930`, `:4254`, `:10447`). *Note:* this
|
||||
file also carries some of the cost-accounting code (below) — separable.
|
||||
|
||||
> `tui_gateway/` (~12k LOC Python) is **shared by both engines** and is **not**
|
||||
> removed when Ink is retired. Only the `ui-tui/` frontend tree goes.
|
||||
|
||||
### 🚩 FLAG FOR REVIEW — Category C, separable from an OpenTUI PR
|
||||
|
||||
These do **not** need to ship with the engine and a reviewer should ask to split
|
||||
them out:
|
||||
|
||||
1. **Provider-reported-cost accounting** (commits `85546bb9e` + `364b93a4b` +
|
||||
`e01b04de4`) — a coherent feature spanning **11 files**: `agent/usage_pricing.py`,
|
||||
`plugins/model-providers/openrouter/__init__.py`,
|
||||
`agent/transports/chat_completions.py`, `agent/agent_init.py`, `run_agent.py`,
|
||||
`agent/conversation_loop.py`, `agent/account_usage.py`, `hermes_state.py`,
|
||||
`gateway/slash_commands.py`, the cost half of `cli.py`, and the
|
||||
`_get_usage`/`_compact_usage_text` blocks of `tui_gateway/server.py` (+ 5 test
|
||||
files). Strongest evidence: commit `85546bb9e` *"gateway: capture real
|
||||
provider-reported cost (openrouter usage accounting)"* — a provider-accounting
|
||||
rework, not a renderer.
|
||||
2. **`plugins/model-providers/openrouter/__init__.py`** — sends
|
||||
`usage:{include:true}`, a provider request-shape change affecting *all*
|
||||
interfaces, not just the TUI (`openrouter/__init__.py:85-90` cites the
|
||||
OpenRouter usage-accounting docs).
|
||||
3. **Worktree lock / dirty-tree preservation** (commit `94765e48f`,
|
||||
`cli.py` + `tests/cli/test_worktree.py`, ~145 lines) — git-worktree lifecycle
|
||||
safety plumbing with **zero TUI references** (`cli.py:1391-1545`, `:1635-1713`).
|
||||
4. **`tools/clarify_tool.py`** (+16/−4) — docstring/schema-description-only fix
|
||||
(commit `16e408f3f`); applies to every interface, trivially separable.
|
||||
|
||||
### ✅ Conversation-loop / role-alternation / prompt-cache correctness verdict: **NO RISK**
|
||||
|
||||
Verified: none of `run_agent.py`, `agent/conversation_loop.py`,
|
||||
`agent/agent_init.py`, `agent/transports/chat_completions.py` touch
|
||||
message-role alternation or the prompt-cache prefix. The
|
||||
`conversation_loop.py` added lines grep clean for
|
||||
`cache_control|alternation|prompt_cach|api_messages`; the cache/alternation
|
||||
machinery (`:57`, `:660-674`, `:759`) is untouched; the PR's insertion at
|
||||
`:1809-1879` is purely additive cost bookkeeping after `cost_result`. **Prompt
|
||||
caching and strict role alternation are preserved.**
|
||||
|
||||
---
|
||||
|
||||
## 5. What this does and does NOT fix
|
||||
|
||||
**Fixes (structurally, by replacing the rendering substrate):** the renderer bug
|
||||
class — layout/scroll/input/copy/mouse/markdown/resize — plus the
|
||||
hand-maintained memory-eviction problem (windowing + Solid keyed `<For>`
|
||||
unmount→`destroy()`→`free()`), and several long-open feature requests (mouse,
|
||||
collapsible tool calls, session title/status bar, double-ESC, chronological
|
||||
thinking/tool ordering).
|
||||
|
||||
**Does NOT fix:** the gateway is unchanged — the biggest single hotspot file in
|
||||
triage is `tui_gateway/server.py`, and whole bug clusters are gateway/Python-side
|
||||
(WS write-timeout/RPC pool, MCP-failure startup freezes, shell.exec denylist).
|
||||
The engine swap addresses rendering/input/scroll/memory; **gateway bugs ride
|
||||
along.** The Effect-boundary hardening does make those failures *visible* (typed
|
||||
events → system lines instead of a frozen spinner) and the TUI auto-heals
|
||||
(crash → backoff → respawn → resume, capped 3/60s).
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature parity matrix (vs the Ink TUI)
|
||||
|
||||
Verbatim, detailed, surface-by-surface with `file:line` evidence:
|
||||
**`docs/plans/opentui-ink-parity-matrix.md`** (interactive/filterable version in
|
||||
the HTML write-up). Headline state:
|
||||
|
||||
| Surface | State |
|
||||
|---|---|
|
||||
| Transcript rendering (scrollbox, markdown, code, diffs, collapsible tools, reasoning, chronological order, windowing) | **full parity (9/9)** |
|
||||
| Blocking prompts (approval/clarify/sudo/secret/confirm) | **full parity (5/5)** |
|
||||
| Theming (skins, light/dark, ANSI-256 norm) | **full parity** |
|
||||
| Mouse / copy (tracking, selection, multi-click, OSC52, click-to-expand, wheel accel) | **full parity** |
|
||||
| Resilience (crash auto-heal + resume) | **parity++ (exponential backoff)** |
|
||||
| Composer / input | near parity — **missing: external editor (Ctrl+G → `$EDITOR`)**; ghost-text autosuggest partial |
|
||||
| Slash commands | core parity — **missing: `/setup`, `/redraw`, `/plugins`, `/voice`**; `/undo` prefill + `/image` partial |
|
||||
| Status bar / header chrome | almost all closed — **missing: MCP-servers panel, profile-in-prompt** |
|
||||
| Agent surfaces | most shipped — **missing: voice indicators, browser/CDP indicator** |
|
||||
| Utility commands | **missing: `/redraw`, `/setup`**; rest present |
|
||||
|
||||
> The original PR-draft gap list was **substantially stale** — the WIP since
|
||||
> shipped context %/token bar, cost, compressions, duration, update banner, todos
|
||||
> panel, activity feed, notifications, background-task indicator, **and per-tool
|
||||
> renderers** (the "every tool renders the same" claim is false:
|
||||
> `view/tools/registry.tsx` has dedicated renderers).
|
||||
|
||||
### Genuinely-remaining parity gaps
|
||||
|
||||
- [ ] **External editor (Ctrl+G → `$EDITOR`)** — highest-impact missing composer affordance
|
||||
- [ ] MCP-servers detail panel; profile-in-prompt marker
|
||||
- [ ] Voice indicators (listening/transcribing/REC/STT) + `/voice`
|
||||
- [ ] Browser/CDP connection indicator + `/browser`
|
||||
- [ ] `/setup` wizard handoff, `/redraw`, `/plugins` hub
|
||||
- [ ] Draggable scrollbar; sticky-prompt line
|
||||
- [ ] `/undo` prefill into composer; model-picker persist-global toggle; skills-hub install/manage
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout, runtime & risks
|
||||
|
||||
- **Runtime:** plain Node 26 (FFI floor 26.3+) — one runtime, no Bun. (Note: the
|
||||
upstream OpenTUI docs say "requires Bun"; this engine deliberately runs on Node
|
||||
26's experimental `node:ffi` instead — that's the load-bearing runtime decision.)
|
||||
- **Rollback:** Ink is untouched and remains the fallback; reverting is a launcher
|
||||
decision, not a code revert.
|
||||
- **Default-engine selection:** auto-picks OpenTUI only when the host is genuinely
|
||||
set up (Node ≥ 26.3 + built bundle), else Ink; explicit env/config bypasses the
|
||||
probe.
|
||||
- **Known sharp edges:** `libopentui.so` native-lib distribution (P1 upstream:
|
||||
copies can fill `/tmp`); the Dockerfile Node major bump needs full-image CI
|
||||
verification; tree-sitter grammars are fetched from GitHub on first use and
|
||||
cached in `~/.hermes/cache/opentui-parsers/` — air-gapped hosts get plain-text
|
||||
highlighting until the cache is pre-seeded (the fetch never blocks boot and
|
||||
never throws).
|
||||
|
||||
## 8. Try it
|
||||
|
||||
```bash
|
||||
hermes # auto-selects OpenTUI when the host supports it
|
||||
HERMES_TUI_ENGINE=opentui hermes # force the native engine
|
||||
HERMES_TUI_ENGINE=ink hermes # force the legacy Ink engine
|
||||
# preview standalone (no backend), Node 26:
|
||||
cd ui-opentui && npm install
|
||||
node scripts/build.mjs scripts/demo.tsx .demo
|
||||
DEMO_TOTAL=120 HERMES_TUI_MAX_MESSAGES=80 \
|
||||
node --experimental-ffi --no-warnings .demo/demo.js # inside a TTY
|
||||
```
|
||||
|
||||
Requires Node 26.3+. On older Node / Windows / Termux it auto-falls-back to Ink.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — source-of-truth files in this repo
|
||||
|
||||
| Topic | File |
|
||||
|---|---|
|
||||
| Non-core change audit (full) | `docs/research/opentui-noncore-change-audit.md` |
|
||||
| Feature parity matrix (verbatim) | `docs/plans/opentui-ink-parity-matrix.md` |
|
||||
| Benchmark report | `docs/plans/opentui-endgame-benchmark-report.md` |
|
||||
| Gut-check verification | `docs/plans/opentui-gutcheck-verification.md` |
|
||||
| Ink↔OpenTUI capture asymmetry | `docs/plans/opentui-ink-asymmetry-note.md` |
|
||||
| UI screenshots | `docs/research/opentui-screenshots/{ink,opentui}-*.png` |
|
||||
| PR description (prose) | `docs/pr-description-main-doc.md` |
|
||||
| Interactive write-up | `~/projects/opentui-perf-writeup/index.html` (out-of-repo) |
|
||||
@@ -1,73 +0,0 @@
|
||||
# Upstream alignment — how we inherit OpenTUI's performance work for free
|
||||
|
||||
Context (maintainer, 2026-06-11): opencode's 100-message cap was a November-era
|
||||
performance workaround, since obsoleted; the **next OpenTUI version ships
|
||||
native yoga** (≥2× layout performance, more improvements building on it);
|
||||
opencode does not use virtualization.
|
||||
|
||||
## The invariant that makes alignment free
|
||||
|
||||
**We are forkless and public-API-only.** The windowing layer (S1+S2) drives the
|
||||
STOCK `<scrollbox>` through documented surface only — `onSizeChange`,
|
||||
`setFrameCallback`, `scrollTop`/`viewport`/`scrollHeight`, Solid `<Show>`
|
||||
mount/unmount. Zero patches to `@opentui/core`. Every upstream release
|
||||
therefore drops in by bumping three pinned versions in `ui-opentui/package.json`
|
||||
(`@opentui/{core,keymap,solid}`, currently 0.4.0). Keep it that way: any new
|
||||
code that needs core behavior goes through a `boundary/` wrapper, never a
|
||||
patched dependency.
|
||||
|
||||
## What native yoga changes for us (and what it doesn't)
|
||||
|
||||
- **Kills the WASM ratchet** (grow-only linear memory → freeable native
|
||||
allocations). This retro-justifies S2 less, but S2's append-time windowing
|
||||
remains correct: transient mounted peaks still cost handles and RSS.
|
||||
- **Does NOT obsolete windowing.** The binding constraint is the 65,535-slot
|
||||
native handle table: ~47 handles/row × 3,000 stored rows ≈ 141k handles —
|
||||
over the table at ANY layout speed. Windowing is what makes the 3,000-row
|
||||
scrollback possible; yoga's backend is irrelevant to that math.
|
||||
- **Makes windowing feel even better**: 2× layout = cheaper margin remounts =
|
||||
smaller window margins viable and less exposure for the one accepted limit
|
||||
(estimate-height snap under scrollbar jumps). After the bump, re-tune margin/
|
||||
hysteresis against the scroll cell.
|
||||
|
||||
## The shim ledger (delete-on-upstream-fix; all in `ui-opentui/src/boundary/`)
|
||||
|
||||
| shim | what it papers over | delete when |
|
||||
|---|---|---|
|
||||
| `ffiSafe.ts` | u32 draw coords go negative under Node FFI (Bun silently wraps) — ERR_INVALID_ARG_VALUE loop | upstream clamps, or Node FFI path is officially supported |
|
||||
| `nativeHandles.ts` | SyntaxStyle exhaustion crashes mid-mount; degrade-to-unstyled | handle table widened (INDEX_BITS>16) or per-kind tables |
|
||||
| `renderer.ts` exit-signal guard | core 0.4.0 treats SIGPIPE (clipboard spawn) as an exit signal; its own uncaughtException handler allocates a handle and dies (exit-7 masking) | both fixed upstream |
|
||||
| `clipboard.ts` hardening | same SIGPIPE incident class | with the above |
|
||||
|
||||
Each is (a) isolated, (b) inert if upstream fixes the behavior, (c) worth
|
||||
reporting upstream — four concrete, reproduced, root-caused issues. Filing them
|
||||
is the cheapest alignment lever we have: it converts our workarounds into
|
||||
upstream regression tests. (Needs glitch's go-ahead — public repo activity.)
|
||||
|
||||
## The upgrade playbook (per upstream release)
|
||||
|
||||
1. Branch `chore/opentui-X.Y.Z`, bump the three pins, `npm ci`.
|
||||
2. `npm run check` (648 tests; the windowing invariants — identical
|
||||
scrollHeight ON/OFF, byte-stable frames across corrections — are literal
|
||||
assertions and will catch behavioral drift).
|
||||
3. Bench acceptance, sequential: `--cell gate` (determinism digest; EXPECT a
|
||||
new digest if upstream changed rendering — eyeball the frame, re-bless),
|
||||
`--cell mem3000 --msgs 2000` + `--cell scroll --msgs 3000` vs current
|
||||
numbers (300–375MB / p99 6–8ms), `--cell pipeline` (frame pacing ≥22fps).
|
||||
4. Shim audit: try each boundary shim OFF; delete the ones upstream fixed.
|
||||
5. Live tmux smoke (scroll sweep / resize / selection-copy), screenshots.
|
||||
6. Windowing re-tune if layout got faster: margins up or hysteresis down,
|
||||
re-run scroll cell, keep p99 ≤ 17ms gate.
|
||||
|
||||
The bench suite IS the upgrade contract — it's exactly the harness that lets
|
||||
us take every upstream improvement within a day of release, with proof.
|
||||
|
||||
## Questions worth relaying to the maintainer
|
||||
|
||||
1. Any plan to widen the 16-bit native handle table (or split per-kind)?
|
||||
That's our hard ceiling, independent of yoga.
|
||||
2. Is the Node `--experimental-ffi` path on their support radar, or Bun-only?
|
||||
(Native yoga adds new FFI surface; we run Node.)
|
||||
3. Would they take the windowing layer's core-agnostic pieces (exact-height
|
||||
spacer pattern, correction-legality rule) as a documented recipe or
|
||||
framework-level utility? We have it production-shaped with tests.
|
||||
@@ -1,150 +0,0 @@
|
||||
# OpenTUI — Background Activity: agents inspection, background panel, notifications + density
|
||||
|
||||
**Status:** SPEC (brainstormed with glitch 2026-06-13) · target branch `feat/opentui-native-engine`
|
||||
**Hard constraint:** TUI-LAYER ONLY (`ui-opentui/`). **Zero changes to `tui_gateway/server.py` or
|
||||
`run_agent.py` core.** Build only on gateway events/RPCs that already exist. Everything below was
|
||||
feasibility-checked against the live gateway surface (see "Gateway surface" §).
|
||||
|
||||
## Why
|
||||
|
||||
Dogfeedback (screenshots `iznq/qxpe/rpiw/rplj`):
|
||||
1. **Agents dashboard is too crowded** (`rplj`) — master rows dump each subagent's full multi-line
|
||||
prompt; the trace pane is squished. Inspection + transcript reading is "not great."
|
||||
2. **Background processes are basically invisible** (`qxpe`) — completions leak into the transcript
|
||||
as plain lines that read like model output; no panel, no badge, notifications are non-existent.
|
||||
3. **Input zone is too crowded** (`rpiw`) — status bar + composer + agents tray + completion menu +
|
||||
shell note stack under the transcript.
|
||||
|
||||
## Design decisions (from the brainstorm)
|
||||
|
||||
- **Two SEPARATE surfaces, ONE shared substrate.** Background *agents* (delegated subagents) and
|
||||
background *work* (detached runs + OS processes) are visually/feature-wise distinct, but share the
|
||||
underlying tracking + notification + badge plumbing.
|
||||
- **Notifications are multi-channel** on every relevant state change:
|
||||
- **(C) inline card** in the transcript — a distinct, colored, collapsed *system card*, clearly
|
||||
NOT model output (replaces today's plain-line leak).
|
||||
- **(A) ambient badge** — a live count in chrome (status-bar `bg:`/the `⚡ N agents` tray) that
|
||||
flashes on change; you pull-to-inspect. Stays visible while things run.
|
||||
- **OSC desktop** — reuse the EXISTING `boundary/termChrome.ts` (`notify`, OSC 9/99/777, already
|
||||
focus-gated so it only fires when the terminal is blurred).
|
||||
- **Agents surface = inspection only.** No foregrounding / "become the subagent" (that would change
|
||||
core subagent UX — explicitly out of scope). Scannable list + a faithful render of the *already-
|
||||
tracked* live activity (goal/model/reasoning/tool calls/progress/summary). No new fetch.
|
||||
- **Background surface = view + stop.** List runs + OS processes with status/uptime; cancel a run
|
||||
(`session.interrupt`/`subagent.interrupt`); **stop-all** OS processes (`process.stop`). Per-process
|
||||
kill and per-process logs are NOT exposed as RPCs → out of scope under the no-core rule (noted).
|
||||
- **Input density is in scope** (own phase).
|
||||
|
||||
## Gateway surface we build on (verified — all already exist)
|
||||
|
||||
| Need | Mechanism (existing) |
|
||||
|---|---|
|
||||
| Background-run lifecycle | `prompt.background` (start), `background.complete` (event) |
|
||||
| Notifications | `notification.show` / `notification.clear` events — payload `{text, level, kind, ttl_ms, key, id}` |
|
||||
| Subagent stream | `subagent.spawn_requested/start/thinking/tool/progress/complete` events (store already consumes) |
|
||||
| List OS processes | `agents.list` RPC → `{processes:[{session_id, command, status, uptime_seconds}]}` |
|
||||
| Stop OS processes | `process.stop` RPC → `kill_all()` (**all**, not per-process) |
|
||||
| Cancel a run / subagent | `session.interrupt`, `subagent.interrupt` |
|
||||
| List active sessions/runs | `session.active_list`, `session.status` |
|
||||
| Subagent trace (archived) | `spawn_tree.list/load` (already used by `/replay`) |
|
||||
| OSC desktop notify | `boundary/termChrome.ts` `notify(TermNotification)` |
|
||||
|
||||
**Honest limits (no-core constraint):** OS processes get list + stop-all only — no per-process kill
|
||||
(`process_registry.kill_process` exists but isn't an RPC) and no per-process log tail
|
||||
(`read_log` isn't an RPC). If the no-core rule is ever relaxed, each is a ~5-line additive `@method`.
|
||||
|
||||
## Architecture (Approach 1 — substrate-first)
|
||||
|
||||
```
|
||||
gateway events ──► store: backgroundActivity slice ──► derived counts/state
|
||||
│ │
|
||||
├─► notificationDispatcher ─────────┼─► (C) inline card (transcript)
|
||||
│ (card + badge + OSC) ├─► (A) ambient badge (statusBar/tray)
|
||||
│ └─► OSC via termChrome.notify
|
||||
├─► Surface 1: AgentsDashboard (revamp) — list + rich activity pane
|
||||
└─► Surface 2: BackgroundPanel (new) — runs + processes, stop
|
||||
```
|
||||
|
||||
### Shared substrate (the "underneath" both surfaces use)
|
||||
|
||||
- **`logic/backgroundActivity.ts`** (new) — pure model + reducers. Types:
|
||||
- `BackgroundRun` (from `prompt.background`/`background.complete`/`session.active_list`):
|
||||
`{ id, label, status: 'running'|'complete'|'failed'|'cancelled', startedAt, summary? }`
|
||||
- `BackgroundProcess` (from `agents.list`): `{ sessionId, command, status, uptimeSeconds }`
|
||||
- `Notification` (from `notification.show`): `{ id, key?, text, level, kind, ttlMs?, at }`
|
||||
- Pure helpers: `applyNotification`, `clearNotification(key)`, counts (`runningCount`),
|
||||
`mergeProcessList`, dedupe by `key`/`id`. Fully unit-testable (no renderer).
|
||||
- **`store.ts`** — a `backgroundActivity` slice + event handlers for `notification.show/clear`,
|
||||
`background.complete`, and a polled `agents.list` snapshot (poll only while a panel/badge is live,
|
||||
or piggyback existing cadence). Existing `subagent.*` handling is untouched.
|
||||
- **`logic/notificationDispatcher.ts`** (new, pure) — given a state-change, decide the channels:
|
||||
returns `{ card?: SystemCard, badge: delta, osc?: TermNotification }`. The boundary calls
|
||||
`termChrome.notify` for the OSC part; the store appends the card + bumps the badge.
|
||||
|
||||
### Surface 1 — Agents inspection overlay (revamp `view/overlays/agentsDashboard.tsx`)
|
||||
|
||||
- **Master list rows = ONE line each:** `<statusGlyph> <truncated goal (truncRight to width)> · <model>`.
|
||||
No multi-line prompt dump. Selected row highlighted (existing `▸` + accent).
|
||||
- **Detail pane = faithful activity transcript** of the selected agent, styled like the main
|
||||
transcript (not flat dumped lines): goal+model header, then the trace rendered by *type*
|
||||
(reasoning / tool-call+result / progress / final summary), newest last, sticky-bottom, PgUp/PgDn.
|
||||
- Requires giving `SubagentInfo.trace` light typing (`{ kind:'tool'|'reasoning'|'progress'|'summary', text }`)
|
||||
instead of `string[]`, populated where `subagent.*` events are reduced. Internal data-shape
|
||||
change only; no gateway change.
|
||||
- Keep Esc/q close, ↑↓ select. Reuse theme + `truncRight` from statusBar.
|
||||
|
||||
### Surface 2 — Background panel (new `view/overlays/backgroundPanel.tsx`)
|
||||
|
||||
- **Two sections:** *Runs* (background agent runs) and *Processes* (OS processes from `agents.list`).
|
||||
- Each row: status glyph + label/command (truncated) + uptime/elapsed + status.
|
||||
- **Actions:** `↑↓` select; on a *run* → `c` cancel (`session.interrupt`/`subagent.interrupt`);
|
||||
global **stop-all processes** (`x` → `process.stop`, confirm). Esc/q close.
|
||||
- **Access:** new client slash `/bg` (alias `/background`, `/jobs`) in `logic/slash.ts` CLIENT set →
|
||||
`store.openBackgroundPanel()`. Also reachable from the ambient badge.
|
||||
- Poll `agents.list` on open + on a light interval while open; stop polling on close.
|
||||
|
||||
### Notifications (the (C)+(A)+OSC wiring)
|
||||
|
||||
- **(C) inline card** — a new transcript element `view/notificationCard.tsx`: a bordered/colored,
|
||||
`selectable:false` system card keyed by `notification.id`, level-tinted (`info/warn/error`),
|
||||
collapsed to one line by default with the `kind` + `text`; clearable by `notification.clear` key.
|
||||
Appended into the message stream as a distinct row type (NOT a plain `system` text line). Replaces
|
||||
the current plain-line leak. (`/details` interplay: cards are chrome, always shown, never windowed.)
|
||||
- **(A) ambient badge** — `statusBar.tsx` `bg: N` segment (already reserved) bound to
|
||||
`runningCount()`; the `agentsTray.tsx` count already exists — extend it to "agents + background."
|
||||
Flash/recolor on a fresh notification (brief).
|
||||
- **OSC** — on `notification.show` with a terminal level (complete/failed), call
|
||||
`termChrome.notify({title, body})` (already focus-gated). No new escape-sequence code.
|
||||
|
||||
### Input-zone density pass (`view/composer.tsx` / `view/App.tsx`)
|
||||
|
||||
- Audit what stacks under the transcript and collapse/gate: the `⚡ N agents` tray line folds into
|
||||
the ambient badge (shrinks one line); ensure the shell-mode note, completion menu, and status bar
|
||||
don't co-stack more than necessary. Concrete rules decided with a tmux density pass (ASCII-mocked,
|
||||
approved) — kept minimal; no behavior change, just fewer competing chrome lines.
|
||||
|
||||
## Phases (implementation order — each gated + tmux-smoked + committed)
|
||||
|
||||
- **P1 — Notification substrate** (`backgroundActivity.ts` + `notificationDispatcher.ts` + store
|
||||
slice + `notificationCard.tsx` + badge wiring + OSC call). Highest visible win; the shared core.
|
||||
- **P2 — Agents inspection revamp** (`agentsDashboard.tsx` + typed `trace`). De-crowds `rplj`.
|
||||
- **P3 — Background panel** (`backgroundPanel.tsx` + `/bg` + actions). New surface.
|
||||
- **P4 — Input density pass.** Folds the tray into the badge; trims co-stacked chrome.
|
||||
|
||||
## Testing / gates (per phase)
|
||||
|
||||
- **Pure logic** (`backgroundActivity`, `notificationDispatcher`, slash `/bg` routing,
|
||||
trace-typing) → vitest unit tests, TDD where natural.
|
||||
- **Views** → headless frame tests (`renderProbe`) for the card, the de-crowded dashboard row
|
||||
format, the background panel sections; + **live tmux smoke** (`tmux-pane-screenshot`) for each
|
||||
surface using a seeded-store harness (the `uxSmoke` pattern: `store.apply`/`applyInfo`/
|
||||
`commitSnapshot` + canned events).
|
||||
- **Gate** `cd ui-opentui && npm run check` green (judge by real exit, not a piped tail) after each
|
||||
phase; rebuild `dist/main.js`; commit `opentui(v6): …` (no attribution) and push per standing instr.
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- Foregrounding / "becoming" a subagent (B/C from the brainstorm) — would change core subagent UX.
|
||||
- Per-process kill + per-process log tail for OS processes — needs additive gateway RPCs (no-core veto).
|
||||
- "Collect result into transcript" for finished runs — deferred (Q6=B, view+stop only).
|
||||
- Any change to `tui_gateway/server.py` / `run_agent.py`.
|
||||
@@ -1,248 +0,0 @@
|
||||
# Plan — OpenTUI composer/UX batch (10 features)
|
||||
|
||||
> **STATUS: SHIPPED (2026-06-13).** All 10 features implemented, gate green
|
||||
> (ui-opentui 714 tests + 316 gateway + 25 cost tests), F5/F6 verified live via
|
||||
> tmux screenshot. Commits: `f4dacc68e` (F1/F2/F7/F8/F8b/F9/F10), `20d516ae9`
|
||||
> (F4/F5/F6), `9aa5e54be` (F3). Decisions taken: **D1 = cursor-aware onType**
|
||||
> (threaded `ta.cursorOffset`); **D2 = chrome cost is Nous-header-only via a new
|
||||
> `nous_header_cost_usd`, `/usage` page kept full via `real_session_cost_usd`**.
|
||||
> F10 (right-pinned cwd) was added mid-session by the user.
|
||||
|
||||
**Branch:** `feat/opentui-native-engine` · **Engine:** `ui-opentui/` (Node 26)
|
||||
**Gate:** `cd ui-opentui && PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH" npm run check` → exit 0.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Nine UX fixes for the native composer + clarify prompt. **8 of 9 are front-end-only**
|
||||
in `ui-opentui/`; only F3 (cost) touches the Python gateway. Every backend the new
|
||||
behaviour needs (`shell.exec`, `complete.path` with `@file:`/`@folder:`/fuzzy) **already
|
||||
exists** — most of this is client wiring, not new RPC surface. No new core tools, no new
|
||||
`HERMES_*` env vars, no prompt-cache impact (composer/prompt are client-render only).
|
||||
|
||||
| # | Symptom | Fix site | Backend |
|
||||
|---|---|---|---|
|
||||
| F1 | bare `/` opens the modal | `logic/slash.ts:115` `planCompletion` | none |
|
||||
| F2 | `/abs/path` text triggers slash | `logic/slash.ts:115` + `logic/skillMatch.ts` | none |
|
||||
| F3 | cost wrong / shows for non-Nous | `tui_gateway/server.py` + `agent/usage_pricing.py` | gateway |
|
||||
| F4 | can't paste until composer focused | `view/composer.tsx` onPaste/focus | none |
|
||||
| F5 | clarify ugly (no wrap, weak diff, "Other" is a row) | `view/prompts/clarifyPrompt.tsx` rewrite | none |
|
||||
| F6 | clarify arrows scroll the transcript | same rewrite (preventDefault) | none |
|
||||
| F7 | slash highlight/menu dies after line 1 | `logic/slash.ts:114` | none |
|
||||
| F8 | file mention dies after line 1 | `logic/slash.ts:114` | none |
|
||||
| F8b | `@` should be the ONLY file-mention trigger | `logic/slash.ts:93` `isPathLike` | none |
|
||||
| F9 | `!cmd` → run bash, show result | `entry/main.tsx` submit + new system render | uses existing `shell.exec` |
|
||||
|
||||
---
|
||||
|
||||
## F1 + F2 + F7 + F8 + F8b — the completion trigger (`logic/slash.ts`)
|
||||
|
||||
All five live in one ~10-line function, `planCompletion` (slash.ts:113-121). Current:
|
||||
|
||||
```ts
|
||||
export function planCompletion(text: string): CompletionPlan | null {
|
||||
if (text.includes('\n')) return null // ← F7/F8 die here
|
||||
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } } // ← F1/F2
|
||||
const word = /(\S+)$/.exec(text)?.[1]
|
||||
if (word && isPathLike(word)) { ... complete.path ... } // ← F8b: too many triggers
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### F1/F2 — slash only for a real command token
|
||||
- A bare `/` (no char yet) must **not** query. Require `/` + at least one name char.
|
||||
- A `/abs/path` (slash followed by a path with more `/`) is **not** a command — it's
|
||||
text. The slash menu should only fire when the FIRST token matches the command
|
||||
grammar (`/[A-Za-z0-9][\w.-]*` — the `NAME_RE` already in skillMatch.ts:51, which
|
||||
excludes `/`). `/usr/bin` fails NAME_RE → no slash menu.
|
||||
- Concretely: replace `text.startsWith('/')` with: the text starts with `/`, and the
|
||||
first whitespace-delimited token after the `/` is non-empty AND matches `NAME_RE`
|
||||
(i.e. `/m`, `/model foo` → yes; `/`, `/usr/bin`, `/./x` → no). Reuse `slashTokens`
|
||||
/`NAME_RE` from skillMatch.ts so the trigger and the highlighter share one grammar.
|
||||
|
||||
### F7/F8 — completion must survive newlines (shift+enter)
|
||||
- `if (text.includes('\n')) return null` is the bug. It was a blunt guard so a multi-line
|
||||
paste wouldn't spam path-completion. The right rule operates on the **current line /
|
||||
current token at the cursor**, not the whole buffer.
|
||||
- The composer passes the full `plainText` to `onType`. We don't currently pass the
|
||||
cursor offset. **Decision D1 (below):** either (a) thread the cursor offset into
|
||||
`onType` and complete the token under the cursor, or (b) cheap interim — slice to the
|
||||
**last line** (`text.slice(text.lastIndexOf('\n')+1)`) and run the existing logic on
|
||||
that. (a) is correct (mid-buffer edits), (b) is 1 line and covers the reported case
|
||||
(typing at the end on line N). Recommend (a) for correctness; it also future-proofs
|
||||
@-mention mid-line.
|
||||
- Slash *highlighting* (skillMatch.ts `slashTokens`) **already scans multi-line text
|
||||
correctly** (it iterates the whole string, newline-aware via `nativeCharOffset`). So
|
||||
F7's "highlighting stopped" is really the same `planCompletion` newline bail starving
|
||||
the menu; the highlight token itself still styles. Verify in the live smoke.
|
||||
|
||||
### F8b — `@` is the only mention trigger
|
||||
- `isPathLike` (slash.ts:93) currently returns true for `@`, `~`, `./`, `../`, `/`, or
|
||||
any word containing `/`. The user wants **`@`-only** (drop `~`/`./`/bare paths as
|
||||
mention triggers). Narrow it to `word.startsWith('@')`.
|
||||
- The gateway `complete.path` (server.py:8543) already special-cases `@` richly
|
||||
(`@file:`, `@folder:`, `@diff`, `@staged`, `@url:`, `@git:`, fuzzy basename search).
|
||||
Its `~`/`./` branches become dead trigger paths from this TUI — leave the gateway code
|
||||
(Ink still uses the path forms; it's shared) but stop emitting those queries from
|
||||
ui-opentui. **No gateway change.**
|
||||
- Net: typing `@` (even bare) opens the mention menu via the `@`-bare branch at
|
||||
server.py:8555. Picking splices `@file:rel/path` etc. (existing accept path,
|
||||
`completionFrom` honoured).
|
||||
|
||||
**Tests:** extend `test/slash.test.ts` — `planCompletion('/')` → null; `planCompletion('/usr/bin')`
|
||||
→ null; `planCompletion('/model')` → complete.slash; multi-line `"a\n/mod"` → complete.slash
|
||||
on the trailing token; `"~/foo"` / `"./x"` → null (no longer path-like); `"@foo"` → complete.path.
|
||||
Keep them as behaviour assertions, not snapshots.
|
||||
|
||||
---
|
||||
|
||||
## F3 — cost: Nous-portal headers only (`tui_gateway` + `agent/usage_pricing.py`)
|
||||
|
||||
**Current:** `_get_usage` (server.py:2157-2167) sets `cost_usd` from
|
||||
`real_session_cost_usd(agent)` (usage_pricing.py:887), which sums **two** provider-reported
|
||||
sources:
|
||||
1. `agent.session_actual_cost_usd` — OpenRouter `usage.cost` accumulator.
|
||||
2. `agent.get_credits_spent_micros()` — Nous `x-nous-credits-*` header delta.
|
||||
|
||||
The TUI already **hides** the cost segment when `cost_usd` is absent (statusBar.tsx:241-243,
|
||||
`costText` returns '' when `costUsd === undefined`) — so this is purely "which sources count."
|
||||
|
||||
**User's intent (F3):** cost should come **only from the Nous portal headers**; suppress it
|
||||
for every other route (cache-token pricing is unreliable across the model long tail).
|
||||
|
||||
**Change:** make the OpenRouter accumulator source conditional on the route being Nous, OR
|
||||
drop source #1 entirely so only the header delta (source #2) feeds `cost_usd`. Source #2 is
|
||||
intrinsically Nous-only (the header only exists on Nous-portal responses), so dropping #1
|
||||
achieves "Nous-header-only" with one edit.
|
||||
|
||||
> **DECISION D2 (needs glitch's confirm):** Drop OpenRouter's `session_actual_cost_usd`
|
||||
> source from `real_session_cost_usd`? Trade-off: OpenRouter's `usage.cost` is itself
|
||||
> *provider-reported* (the real charged number, not a Hermes estimate), so OR users lose an
|
||||
> accurate readout. But it removes the cache-token guesswork the user is worried about and
|
||||
> matches "only via the headers when using nous portal" literally.
|
||||
> **Recommended default (implementing unless told otherwise):** gate source #1 so it only
|
||||
> contributes when the active route is the Nous portal (base_url == nous inference api),
|
||||
> else it's dropped. This keeps the segment Nous-only AND avoids touching shared OR/CLI
|
||||
> behaviour for the `/usage` page. If even Nous-route OR-accumulator is unwanted, collapse
|
||||
> to header-only.
|
||||
|
||||
**Scope guard:** `real_session_cost_usd` is also consumed by `/usage` page rendering
|
||||
(server.py:2237) and DB usage totals. Prefer a NEW, status-bar-specific helper
|
||||
(e.g. `nous_header_cost_usd(agent)`) wired only into `_get_usage`'s `cost_usd`, leaving the
|
||||
`/usage` accounting page untouched — so we don't regress the full cost report. Confirm with
|
||||
the gate + a gateway unit test (`tui_gateway` tests) that a non-Nous session yields no
|
||||
`cost_usd`.
|
||||
|
||||
---
|
||||
|
||||
## F4 — paste while composer unfocused (`view/composer.tsx`)
|
||||
|
||||
**Current:** the global keyboard handler reclaims focus on a *printable keystroke*
|
||||
(`isPrintableKey`, composer.tsx:415-417). A **bracketed-paste event is not a keystroke** —
|
||||
it arrives at `onPaste` only if the textarea is focused, so an unfocused composer drops it;
|
||||
the user has to click/type first.
|
||||
|
||||
**Fix:** the renderer delivers paste through the focused renderable. Two options:
|
||||
- (a) Keep focus on the composer more aggressively (opencode keeps the prompt focused via a
|
||||
reactive effect). Risky — fights transcript scroll focus.
|
||||
- (b) **Recommended:** handle paste at the renderer/global level. Check whether OpenTUI
|
||||
exposes a global paste hook (`renderer.on('paste')` or a keyboard event with
|
||||
`key.name === 'paste'` / a paste event type). If a global paste signal exists, on paste:
|
||||
`ta.focus()` then route the bytes into the existing `onPaste` logic (image / placeholder /
|
||||
insert). **Must verify the API in the `opentui` skill before coding** (skill_view
|
||||
references/docs). If only the focused-renderable paste exists, fall back to (a) scoped:
|
||||
refocus the composer whenever no overlay/prompt is open and focus drifted (a
|
||||
`createEffect` watching focus + `store.state.prompt`/overlay state).
|
||||
|
||||
**Verify in live smoke** (tmux + tmux-pane-screenshot): scroll the transcript to drop focus,
|
||||
then paste — text must land without a prior click.
|
||||
|
||||
---
|
||||
|
||||
## F5 + F6 — clarify prompt rewrite (`view/prompts/clarifyPrompt.tsx`)
|
||||
|
||||
Screenshot `/tmp/screenshots/SCR-20260613-iznq.png` confirms: long options run off the right
|
||||
edge (no wrap), options differ only by `▶`/`—` glyphs (no numbers, weak), and "✎ Other…" is
|
||||
a `<select>` row that *switches* to an input on Enter rather than being an inline input.
|
||||
|
||||
**Current:** one native `<select>` over `[...choices, {Other}]` (clarifyPrompt.tsx:61-75).
|
||||
Native `<select>` doesn't wrap long rows and (F6) doesn't `preventDefault` arrows, so they
|
||||
leak to the transcript scrollbox.
|
||||
|
||||
**Rewrite plan (verify renderable API in `opentui` skill first):**
|
||||
- Replace native `<select>` with a **custom keyboard-driven list** (a `For` over options +
|
||||
a `selected` signal + `useKeyboard` with `key.preventDefault()` on up/down/enter — same
|
||||
pattern the composer's `routeMenuKey` uses; F6 fixed by preventDefault so arrows never
|
||||
reach the scrollbox).
|
||||
- **Wrapping (F5):** render each option as a `<text>` that wraps to the box width (no fixed
|
||||
single-line). Indent continuation lines under the option label. Confirm `<text>` soft-wrap
|
||||
behaviour in the opentui skill (it wraps by default within a flex box of bounded width).
|
||||
- **Differentiation (F5):** number every option `1.` `2.` … (digit hotkeys optional, nice-to-
|
||||
have), and give the selected row the themed `selectionBg` + accent fg (the composer's
|
||||
`completionCurrentBg` model), not just a glyph. Number + background + accent = three signals.
|
||||
- **Inline custom answer (F5):** render the `<input>` **inside the same screen, always
|
||||
present** as the last "row" (an `Other:` labeled input), instead of an item that toggles.
|
||||
Selecting/focusing it lets the user type; Enter in it submits the free text. Keep the
|
||||
existing `clarify.respond {answer}` wiring. Arrow-down past the last choice lands on the
|
||||
input; arrow-up from the input returns to the list (focus handoff like the composer↔tray).
|
||||
- Keep Esc/Ctrl+C → cancel (clarifyPrompt.tsx:31-33).
|
||||
|
||||
**Reference:** opencode's selection/list components in `~/github/opencode/packages/tui` for
|
||||
the wrap + highlight + hotkey idiom; the composer dropdown (composer.tsx:441-458) for the
|
||||
in-repo highlight/selectable pattern.
|
||||
|
||||
**Tests:** `test/render.test.tsx`-style headless frame — long option wraps (frame contains the
|
||||
tail of a long choice on a 2nd line), selected row shows numbered + highlighted, custom input
|
||||
present in the same frame, arrow keys don't change scrollTop (assert transcript scroll
|
||||
unchanged), Enter on a choice → onAnswer(choice), Enter in input → onAnswer(typed).
|
||||
|
||||
---
|
||||
|
||||
## F9 — `!cmd` runs bash (`entry/main.tsx` + a system render)
|
||||
|
||||
**Backend exists:** `shell.exec` (server.py:10301) runs the command (30s timeout, dangerous/
|
||||
hardline-command guards, returns `{stdout, stderr, code}`).
|
||||
**Ink parity reference:** `ui-tui/src/app/useSubmission.ts:291` — `full.startsWith('!')` →
|
||||
`shellExec(full.slice(1).trim())` → appends a user line `!cmd` + a system line with output;
|
||||
the prompt glyph flips while the buffer starts with `!` (appLayout.tsx:178).
|
||||
|
||||
**Plan (ui-opentui):**
|
||||
- In the entry `submit` (main.tsx:517-520), add a branch BEFORE the slash check:
|
||||
`if (text.startsWith('!')) { runShell(text.slice(1).trim()); return }`.
|
||||
- `runShell(cmd)`: `store.pushUser('!' + cmd)` (echo the invocation in the transcript), then
|
||||
`gateway.request('shell.exec', { command: cmd })`; on resolve, `store.pushSystem` the
|
||||
combined `stdout`/`stderr` (or the error message / non-zero `code`); on reject,
|
||||
pushSystem the error. Detached `runFork` like `submitPrompt`. No session turn, no model call.
|
||||
- Empty `!` (just the bang) → no-op (or a hint), matching Ink.
|
||||
- **Optional polish (parity, not required):** flip the composer prompt glyph (or tint) while
|
||||
the buffer starts with `!`, like Ink's appLayout. Low-risk; do only if cheap.
|
||||
|
||||
**Tests:** entry-level/logic test that a `!`-prefixed submit routes to `shell.exec` (not
|
||||
`prompt.submit`), and the system line renders stdout. Mirror the slashMenu.test harness
|
||||
(fake gateway capturing the method).
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & fences (subagent-driven; disjoint files)
|
||||
|
||||
Parallel-safe groups (disjoint file fences):
|
||||
1. **slash trigger** — `logic/slash.ts` (+ `logic/skillMatch.ts` reuse) + `test/slash.test.ts`. (F1/F2/F7/F8/F8b)
|
||||
2. **clarify** — `view/prompts/clarifyPrompt.tsx` + a clarify test. (F5/F6)
|
||||
3. **shell-exec** — `entry/main.tsx` (edit DIRECTLY — load-bearing) + system render + test. (F9)
|
||||
4. **paste focus** — `view/composer.tsx` (edit directly; verify opentui paste API first). (F4)
|
||||
5. **cost** — `tui_gateway/server.py` + `agent/usage_pricing.py` + gateway test. (F3) — Python, isolated.
|
||||
|
||||
`entry/main.tsx` and `store.ts` are edited directly, never via subagent (handoff rule).
|
||||
Each renderable change: `skill_view(opentui, references/docs/...)` FIRST. Verify every
|
||||
subagent self-report (re-run `npm run check` exit code, read the diff).
|
||||
|
||||
## Open decisions (need glitch)
|
||||
- **D1 (F7/F8):** thread cursor offset into `onType` (correct) vs. last-line slice (cheap)?
|
||||
Recommend cursor offset.
|
||||
- **D2 (F3):** drop OpenRouter cost source entirely, or gate it to the Nous route? Recommend
|
||||
Nous-route gate via a status-bar-only helper, leaving `/usage` accounting intact.
|
||||
|
||||
## Invariants to preserve
|
||||
- Per-conversation prompt caching untouched (all client-render or post-hoc gateway usage).
|
||||
- No new `HERMES_*` env var (these are behaviour, not secrets).
|
||||
- Strict no change-detector tests — assert behaviour/invariants.
|
||||
- Don't regress the `/usage` accounting page when narrowing the chrome cost source.
|
||||
@@ -1,217 +0,0 @@
|
||||
# OpenTUI — usage/credits notice in the composer chrome
|
||||
|
||||
**Status:** spec (not started) · **Engine:** `ui-opentui/` · **Author:** glitch · 2026-06-14
|
||||
|
||||
## Goal
|
||||
|
||||
Render the gateway's **usage / credits notices** as a persistent, level-tinted
|
||||
**chrome banner pinned at the top of the input zone** (directly above the status
|
||||
bar), with the same lifecycle the Ink engine already has — sticky vs TTL,
|
||||
mid-turn hold + turn-end reveal, and "flash-and-yield" for the usage bands.
|
||||
|
||||
Today the OpenTUI engine **receives** these notices but mis-renders them as
|
||||
scrolling inline transcript cards with no lifecycle. This spec fixes that without
|
||||
touching the gateway or the agent (the data already flows correctly).
|
||||
|
||||
## What already exists (verified)
|
||||
|
||||
### The wire (source of truth — do NOT change)
|
||||
The gateway emits one event for every notice, snake_case payload:
|
||||
|
||||
```
|
||||
notification.show payload { text, level, kind, ttl_ms, key, id } # tui_gateway/server.py:2878
|
||||
notification.clear payload { key } # tui_gateway/server.py:2890
|
||||
```
|
||||
|
||||
These come from `AgentNotice` (`agent/credits_tracker.py:177`). The credits
|
||||
policy (`evaluate_credits_notices`, `agent/credits_tracker.py:245`) emits exactly
|
||||
four notices — the full catalog this feature renders:
|
||||
|
||||
| `key` | `text` (already glyphed by policy) | `level` | `kind` | `ttl_ms` | lifecycle |
|
||||
|-----------------------|-------------------------------------------------|-----------|----------|----------|----------------|
|
||||
| `credits.usage` | `⚠/• Credits N% used · $X cap` (bands 50/75/90) | info/warn | `sticky` | — | flash-and-yield |
|
||||
| `credits.grant_spent` | `• Grant spent · $X top-up left` | info | `sticky` | — | flash-and-yield |
|
||||
| `credits.depleted` | `✕ Credit access paused · run /usage for balance` | error | `sticky` | — | sticky |
|
||||
| `credits.restored` | `✓ Credit access restored` | success | `ttl` | `8000` | TTL self-expire |
|
||||
|
||||
**Load-bearing facts:**
|
||||
- `text` is **already glyphed** (⚠ • ✕ ✓) by the Python policy — the renderer
|
||||
**must not** prepend another glyph. It only tints by `level`.
|
||||
- `level` includes **`success`** (green) — a level the current OpenTUI parser
|
||||
silently drops to `info`.
|
||||
- `kind` is the **lifecycle marker** (`sticky` | `ttl`), NOT a display label.
|
||||
`id` == `key` (stable per kind, not unique per emission).
|
||||
- Notices are **reconciled**: the policy emits `to_clear` (a `notification.clear`)
|
||||
then `to_show`. A band change clears `credits.usage` then re-shows it.
|
||||
|
||||
### The Ink reference behavior (what we're matching)
|
||||
`ui-tui/src/app/turnController.ts` + `appChrome.tsx`:
|
||||
- `showNotice` (`:181`): if **busy**, hold in `pendingNotice` (latest-wins);
|
||||
if idle, apply now.
|
||||
- `applyNotice` (`:213`): set the visible notice; for `kind: 'ttl'` with
|
||||
`ttl_ms > 0`, arm a self-expiry timer (clearing any prior timer first).
|
||||
- `clearNotice(key)` (`:198`): drop the visible **and** pending notice only when
|
||||
the key matches (a stale clear must not wipe a newer notice).
|
||||
- `flushPendingNotice` (`:245`): at **turn end** (only the real end sites) apply
|
||||
the held notice — its TTL clock starts here, when it first becomes visible.
|
||||
- **Flash-and-yield** (`startMessage`, `:917`): at **turn start**, if the visible
|
||||
notice's key is `credits.usage` or `credits.grant_spent`, clear it — "show
|
||||
once, then get out of the way." `credits.depleted` and others stay sticky. The
|
||||
Python `active` latch keeps the key so it won't re-fire next turn.
|
||||
- Session reset clears all notice state so session A's notice can't bleed into B.
|
||||
- Color by level: `error→error`, `warn→warn`, `success→statusGood`,
|
||||
`info→accent` (`noticeColor`, `appChrome.tsx:192`).
|
||||
|
||||
### The OpenTUI side (what we change)
|
||||
- `notification.show` → `parseNotification` → `pushNotification` → **inline card**
|
||||
in the transcript (`store.ts:832`, `notificationCard.tsx`). All kinds, no
|
||||
lifecycle. The Option B process-completion card (`kind: 'process.complete'`)
|
||||
and `background.complete` (`kind: 'background task complete'`) also use this
|
||||
path — **they must keep working unchanged.**
|
||||
- `parseNotification` coerces `level` to `info|warn|error` only
|
||||
(`backgroundActivity.ts:48`) — drops `success`.
|
||||
- Store carries `lastNotification` (OSC seam), `bgTasks`; **no** `notice` slot.
|
||||
- Theme has `accent`, `warn`, `error`, `ok`/`statusGood`, `muted`
|
||||
(`logic/theme.ts`) — `success` maps to `statusGood`.
|
||||
- Input zone layout (`view/App.tsx:140-211`): a top-bordered column —
|
||||
`<StatusBar>` → composer `<Switch>` → `<AgentsTray>`. The new banner mounts at
|
||||
`App.tsx:144`, **directly above `<StatusBar>`** (the topmost line of the chrome).
|
||||
- Turn lifecycle hooks: `case 'message.start'` (`store.ts:779`, sets
|
||||
`info.running = true`) and `case 'message.complete'` (`store.ts:811`, sets
|
||||
`info.running = false`). `clearTranscript` (`store.ts:631`) is the reset site.
|
||||
- `Date.now()` is used freely in the store (`:877`) — `setTimeout` for TTL is fine.
|
||||
|
||||
## The one design decision: routing
|
||||
|
||||
`kind` is the discriminator. **`notification.show` with `kind === 'sticky'` or
|
||||
`kind === 'ttl'` → the new chrome-notice path; every other kind → the existing
|
||||
inline-card path, untouched.** This mirrors Ink's `Notice.kind: 'sticky' | 'ttl'`
|
||||
exactly, and the credits policy sets `kind` to one of those for all four notices,
|
||||
while the process/background cards use label-strings (`process.complete`,
|
||||
`background task complete`) that are neither — so they stay inline cards. No
|
||||
gateway change, no key-prefix sniffing.
|
||||
|
||||
**Divergence from Ink (intentional):** Ink hides the notice while busy because the
|
||||
FaceTicker shares its one status slot. OpenTUI's busy face (`StatusLine`) lives in
|
||||
the transcript area, so the banner has a **dedicated row** and stays visible
|
||||
through a turn (a depletion warning shouldn't vanish mid-turn). We still **hold
|
||||
new notices** that arrive mid-turn (`pendingNotice`) and reveal them at turn end —
|
||||
matching Ink's "don't pop a fresh banner mid-stream" intent.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1 — parser + type (`logic/backgroundActivity.ts`)
|
||||
1. Widen `ActivityNotification.level` to `'info' | 'warn' | 'error' | 'success'`.
|
||||
2. `coerceLevel`: also accept `'success'` (still fall back to `'info'`).
|
||||
3. Add `export function isChromeNotice(n: ActivityNotification): boolean` →
|
||||
`n.kind === 'sticky' || n.kind === 'ttl'`.
|
||||
4. `parseNotification` already maps `ttl_ms → ttlMs` and preserves `key`/`id` —
|
||||
no shape change beyond the widened level.
|
||||
|
||||
**Tests** (`backgroundActivity.test.ts` or `notificationCard.test.tsx`):
|
||||
`success` survives parse; `kind: 'ttl'` + `ttl_ms` → `ttlMs`; `isChromeNotice`
|
||||
true for sticky/ttl, false for `process.complete`/`''`.
|
||||
|
||||
### Phase 2 — store lifecycle (`logic/store.ts`)
|
||||
Add state + a private (non-reactive) timer handle in `createSessionStore`:
|
||||
- `notice: ActivityNotification | null` (visible chrome notice) — new state field,
|
||||
init `null`.
|
||||
- `pendingNotice: ActivityNotification | null` — held mid-turn, init `null`.
|
||||
- `let noticeTimer: ReturnType<typeof setTimeout> | undefined` (closure var).
|
||||
|
||||
Functions (port of `turnController`):
|
||||
- `showNotice(n)`: `state.info.running ? setState('pendingNotice', n) : applyNotice(n)`
|
||||
(latest-wins — assigning replaces any prior pending).
|
||||
- `applyNotice(n)`: clear `noticeTimer`; `setState('notice', n)`; if
|
||||
`n.kind === 'ttl' && n.ttlMs && n.ttlMs > 0`, arm `setTimeout(n.ttlMs)` that
|
||||
clears `notice` only if `state.notice?.id === n.id` (defensive guard).
|
||||
- `clearNotice(key)`: if `state.pendingNotice?.key === key` → null it; if
|
||||
`state.notice?.key === key` → clear timer + null `notice`.
|
||||
- `flushPendingNotice()`: if `state.pendingNotice` → `applyNotice` it, null pending.
|
||||
- `clearNoticeState()`: null `notice` + `pendingNotice`, clear timer.
|
||||
|
||||
Wire into the event reducer:
|
||||
- `notification.show` (`store.ts:832`): route —
|
||||
`const n = parseNotification(...); if (!n) break; if (isChromeNotice(n)) showNotice(n); else pushNotification(n)`.
|
||||
(Still record `lastNotification` for the OSC seam in **both** paths — extract
|
||||
the `setState('lastNotification', {...n})` so a chrome notice also pings a
|
||||
blurred terminal, matching the inline-card behavior.)
|
||||
- `notification.clear` (`store.ts:837`): call **both** `clearNotificationCards(key)`
|
||||
(cards) **and** `clearNotice(key)` (chrome) — a key only ever lives in one, so
|
||||
calling both is safe and avoids guessing.
|
||||
- `message.start` (`store.ts:779`): flash-and-yield — if
|
||||
`state.notice?.key === 'credits.usage' || === 'credits.grant_spent'` →
|
||||
`clearNotice(state.notice.key)`. (Do this **before** flipping `running` true so
|
||||
the read is clean.)
|
||||
- `message.complete` (`store.ts:811`): call `flushPendingNotice()` (after the
|
||||
`running = false` set, so a held notice reveals on the now-idle bar).
|
||||
- `clearTranscript` (`store.ts:631`) and any session-switch reset:
|
||||
`clearNoticeState()`.
|
||||
|
||||
Export `notice` via the store's state and `showNotice`/`clearNotice` if a test or
|
||||
future slash command needs them.
|
||||
|
||||
**Tests** (`statusNotice.test.ts`, new):
|
||||
- idle `showNotice` → `state.notice` set, no card pushed.
|
||||
- routing: `notification.show` `kind:'sticky'` → `notice` set, **no** transcript
|
||||
card; `kind:'process.complete'` → card pushed, `notice` still null.
|
||||
- mid-turn hold: `message.start` → `showNotice` → `notice` stays null,
|
||||
`pendingNotice` set → `message.complete` → `notice` revealed.
|
||||
- `clearNotice` by key drops visible + pending; non-matching key is a no-op.
|
||||
- TTL: `kind:'ttl', ttlMs:50` auto-clears (vitest fake timers).
|
||||
- flash-and-yield: visible `credits.usage` cleared on `message.start`;
|
||||
`credits.depleted` persists across a start/complete cycle.
|
||||
- `clearTranscript` resets `notice` + `pendingNotice`.
|
||||
- `success` notice keeps its level.
|
||||
|
||||
### Phase 3 — view (`view/noticeBanner.tsx` + `App.tsx`)
|
||||
New `NoticeBanner` (sibling style to `notificationCard.tsx`):
|
||||
- Props: `notice: ActivityNotification | null`, plus terminal width for truncation.
|
||||
- `<Show when={notice}>` — renders nothing when null.
|
||||
- One row, `flexShrink: 0`, `paddingLeft: 1`, `selectable={false}`.
|
||||
- Text rendered **verbatim** (glyph already present), tinted by level:
|
||||
`error→error`, `warn→warn`, `success→statusGood`, `info→accent`.
|
||||
- Truncate to width with `truncRight` (`logic/truncate.ts`) so a long notice can
|
||||
never push the composer or wrap.
|
||||
|
||||
Mount in `App.tsx:144`, the first child of the top-bordered input zone, directly
|
||||
above `<StatusBar store={...} />`:
|
||||
```tsx
|
||||
<box border={['top']} ...>
|
||||
<NoticeBanner notice={props.store.state.notice} /> {/* new */}
|
||||
<StatusBar store={props.store} />
|
||||
...
|
||||
```
|
||||
|
||||
**Tests** (`noticeBanner.test.tsx`, frame): renders the text without adding a
|
||||
glyph; warn→warn color, success→statusGood color; truncates at narrow width;
|
||||
renders an empty frame when `notice` is null.
|
||||
|
||||
### Phase 4 — parity verification + docs
|
||||
- `npm run check` green (prettier + eslint + vitest).
|
||||
- Headless frame dump: a `credits.usage` warn banner above the status bar; a
|
||||
`credits.depleted` error banner surviving a turn; a `credits.restored` success
|
||||
banner that disappears after its TTL.
|
||||
- tmux smoke per `docs/opentui-dev-handoff.md` (inject the three notices via the
|
||||
test harness / a scripted gateway event; screenshot the chrome).
|
||||
- Cross-check the four-notice catalog renders identically in tone to Ink's
|
||||
`appChromeStatusRule` (color-by-level, no double glyph, truncation).
|
||||
|
||||
## Non-goals
|
||||
- No gateway/agent changes — the wire and the policy are the source of truth.
|
||||
- No new notice kinds — render exactly the four the policy emits.
|
||||
- The inline-card path (process/background completions) is **unchanged**.
|
||||
- No status-bar segment changes — the banner is its own row above the bar.
|
||||
|
||||
## Risk / footguns
|
||||
- **Schema decode-at-boundary**: `notification.show` payload is a loose Record
|
||||
read by `parseNotification`, not strict-decoded — a wrong-typed field won't blank
|
||||
the bar (unlike `applyInfo`). Keep the loose reads.
|
||||
- **createStore reference-aliasing**: store `notice` and `pendingNotice` distinct
|
||||
objects; when applying pending, it's already its own object — don't alias it to
|
||||
`lastNotification`. (See `[[solid-createstore-reference-aliasing]]`.)
|
||||
- **Timer leak**: `clearNoticeState` must clear `noticeTimer`; ensure session
|
||||
reset and store dispose clear it so a TTL callback can't fire into a dead store.
|
||||
- **Routing regression**: assert in tests that `process.complete` /
|
||||
`background task complete` still produce **cards**, not banners — the whole
|
||||
feature hinges on the `kind` discriminator.
|
||||
@@ -1241,6 +1241,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
message_id = (msg.get("result") or {}).get("message_id")
|
||||
else:
|
||||
message_id = getattr(msg, "message_id", None)
|
||||
if message_id is not None:
|
||||
# Telegram won't echo rich content in reply_to_message, so remember
|
||||
# what we sent — replies to this message resolve via this index.
|
||||
try:
|
||||
from gateway import rich_sent_store
|
||||
rich_sent_store.record(str(chat_id), str(message_id), content)
|
||||
except Exception:
|
||||
pass
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=str(message_id) if message_id is not None else None,
|
||||
@@ -6700,6 +6708,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
or message.reply_to_message.caption
|
||||
or None
|
||||
)
|
||||
if not reply_to_text:
|
||||
# Rich messages (sendRichMessage — the launchd briefings and
|
||||
# the gateway's own rich finals) are NOT echoed with their
|
||||
# content in reply_to_message; Telegram sends no text,
|
||||
# caption, or api_kwargs for them. Recover the text we sent
|
||||
# from our local send-time index, keyed by message id.
|
||||
try:
|
||||
from gateway import rich_sent_store
|
||||
reply_to_text = rich_sent_store.lookup(
|
||||
str(chat.id), reply_to_id
|
||||
)
|
||||
except Exception:
|
||||
reply_to_text = None
|
||||
|
||||
# Per-channel/topic ephemeral prompt
|
||||
from gateway.platforms.base import resolve_channel_prompt
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Local index of text we've sent via ``sendRichMessage`` (Bot API 10.1).
|
||||
|
||||
Telegram does NOT echo a rich message's content back in ``reply_to_message``
|
||||
when a user replies to it (verified: ``.text``/``.caption`` empty,
|
||||
``.api_kwargs`` None). So replies to the launchd briefings / any rich send
|
||||
arrive with no quotable text and the agent is blind to what was referenced.
|
||||
|
||||
Fix: remember ``message_id -> text`` at send time, look it up by
|
||||
``reply_to_id`` on inbound. This module is the single source of truth for that
|
||||
index.
|
||||
|
||||
Best-effort and dependency-free: every operation swallows errors and degrades
|
||||
to a no-op / ``None`` so it can never break a send or an inbound message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
_MAX_ENTRIES = 1000
|
||||
_MAX_TEXT_CHARS = 2000
|
||||
|
||||
|
||||
def _store_path() -> str:
|
||||
home = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes")
|
||||
return os.path.join(home, "state", "rich_sent_index.json")
|
||||
|
||||
|
||||
def _key(chat_id, message_id) -> str:
|
||||
return f"{chat_id}:{message_id}"
|
||||
|
||||
|
||||
def record(chat_id, message_id, text: Optional[str]) -> None:
|
||||
"""Persist ``text`` for ``(chat_id, message_id)``. No-op on any failure."""
|
||||
if not text or message_id is None or chat_id is None:
|
||||
return
|
||||
path = _store_path()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (FileNotFoundError, ValueError):
|
||||
data = {}
|
||||
data[_key(chat_id, message_id)] = {
|
||||
"t": text[:_MAX_TEXT_CHARS],
|
||||
"ts": int(time.time()),
|
||||
}
|
||||
# Trim oldest by timestamp when over cap.
|
||||
if len(data) > _MAX_ENTRIES:
|
||||
for k, _ in sorted(
|
||||
data.items(), key=lambda kv: kv[1].get("ts", 0)
|
||||
)[: len(data) - _MAX_ENTRIES]:
|
||||
data.pop(k, None)
|
||||
tmp = f"{path}.tmp.{os.getpid()}"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False)
|
||||
os.replace(tmp, path) # atomic; tolerates concurrent writers racing
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def lookup(chat_id, message_id) -> Optional[str]:
|
||||
"""Return stored text for ``(chat_id, message_id)`` or ``None``."""
|
||||
if message_id is None or chat_id is None:
|
||||
return None
|
||||
try:
|
||||
with open(_store_path(), "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
entry = data.get(_key(chat_id, message_id))
|
||||
if isinstance(entry, dict):
|
||||
return entry.get("t") or None
|
||||
except (FileNotFoundError, ValueError, AttributeError):
|
||||
return None
|
||||
return None
|
||||
+18
-5
@@ -8259,10 +8259,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_msg_start_time = time.time()
|
||||
_platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
|
||||
_msg_preview = (event.text or "")[:80].replace("\n", " ")
|
||||
_reply_id = getattr(event, "reply_to_message_id", None)
|
||||
_reply_txt = (getattr(event, "reply_to_text", None) or "")[:80].replace("\n", " ")
|
||||
logger.info(
|
||||
"inbound message: platform=%s user=%s chat=%s msg=%r",
|
||||
"inbound message: platform=%s user=%s chat=%s msg=%r reply_to_id=%s reply_to_text=%r",
|
||||
_platform_name, source.user_name or source.user_id or "unknown",
|
||||
source.chat_id or "unknown", _msg_preview,
|
||||
source.chat_id or "unknown", _msg_preview, _reply_id, _reply_txt,
|
||||
)
|
||||
|
||||
# Get or create session
|
||||
@@ -14368,6 +14370,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
log_message="agent:step hook scheduling error",
|
||||
)
|
||||
|
||||
# Bridge sync event_callback → async hooks.emit for lifecycle events
|
||||
# (e.g. session:compress fires after context compression splits a session)
|
||||
def _event_callback_sync(event_type: str, context: dict) -> None:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_hooks_ref.emit(event_type, context),
|
||||
_loop_for_step,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("event_callback hook error: %s", _e)
|
||||
|
||||
# Bridge sync status_callback → async adapter.send for context pressure
|
||||
_status_adapter = self.adapters.get(source.platform)
|
||||
_status_chat_id = source.chat_id
|
||||
@@ -14702,15 +14715,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
agent.stream_delta_callback = _stream_delta_cb
|
||||
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
|
||||
agent.status_callback = _status_callback_sync
|
||||
|
||||
# Credits / out-of-band notices (usage bands, depletion, restored).
|
||||
# Messaging has no persistent status bar, so each notice is a
|
||||
# standalone push: render to a single plaintext line and deliver via
|
||||
# the shared _deliver_platform_notice rail (honors private/public +
|
||||
# thread metadata). Fires from the agent's sync worker thread, so we
|
||||
# hop onto the gateway loop with safe_schedule_threadsafe — same
|
||||
# hop onto the gateway loop with safe_schedule_threadsafe - same
|
||||
# pattern as _status_callback_sync. The fired-once latch lives on the
|
||||
# cached agent and persists across turns, so a band crosses → one
|
||||
# cached agent and persists across turns, so a band crosses -> one
|
||||
# push (no per-turn re-nag). Recovery ("✓ Credit access restored")
|
||||
# rides the same show path (it's emitted as a success notice, not a
|
||||
# clear). The clear callback is a no-op: a sent platform message
|
||||
@@ -14734,6 +14746,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
agent.notice_callback = _notice_callback_sync
|
||||
agent.notice_clear_callback = None
|
||||
agent.event_callback = _event_callback_sync
|
||||
agent.reasoning_config = reasoning_config
|
||||
agent.service_tier = self._service_tier
|
||||
agent.request_overrides = turn_route.get("request_overrides") or {}
|
||||
|
||||
+2
-16
@@ -145,16 +145,8 @@ def build_top_level_parser():
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
# nargs="?" + const=True: bare `--resume` parses to the sentinel True,
|
||||
# which `hermes --tui` turns into the session picker
|
||||
# (HERMES_TUI_RESUME=picker). `--resume <id|title>` is unchanged.
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
help=(
|
||||
"Resume a previous session by ID or title. With --tui, bare "
|
||||
"--resume (no argument) opens the session picker."
|
||||
),
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
@@ -309,14 +301,8 @@ def build_top_level_parser():
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
# Same bare-flag picker sentinel as the top-level --resume.
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Resume a previous session by ID (shown on exit). With --tui, "
|
||||
"bare --resume opens the session picker."
|
||||
),
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
|
||||
@@ -1039,6 +1039,64 @@ class CLICommandsMixin:
|
||||
print(" Usage: /personality <name>")
|
||||
print()
|
||||
|
||||
def _handle_pet_command(self, cmd: str):
|
||||
"""Toggle, browse, or adopt a petdex mascot.
|
||||
|
||||
``/pet`` / ``/pet toggle`` → flip ``display.pet.enabled`` on/off
|
||||
``/pet list`` → browse the petdex gallery
|
||||
``/pet scale <n>`` → resize the pet everywhere (e.g. 0.5)
|
||||
``/pet <slug>`` → adopt (install if needed) + make active
|
||||
``/pet off`` → disable (alias for toggle-off)
|
||||
|
||||
Writes ``display.pet.*`` to config; the CLI/TUI/desktop pet surfaces
|
||||
pick the change up on their next poll, so the pet appears shortly.
|
||||
"""
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError
|
||||
from hermes_cli.pets import _set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display
|
||||
|
||||
parts = cmd.split(maxsplit=1)
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
low = arg.lower()
|
||||
|
||||
if not arg or low == "toggle":
|
||||
enabled, name, err = toggle_pet_display()
|
||||
if err:
|
||||
print(f"(x_x) {err}")
|
||||
return
|
||||
if enabled:
|
||||
print(f"(^_^)b {name} is out — it'll pop in shortly.")
|
||||
else:
|
||||
print(f"(-_-)zzZ {name} put away." if name else "(-_-)zzZ Pet put away.")
|
||||
return
|
||||
|
||||
if low in ("list", "gallery", "browse", "all"):
|
||||
print_pet_gallery()
|
||||
return
|
||||
|
||||
if low == "scale" or low.startswith("scale "):
|
||||
value = arg[len("scale"):].strip()
|
||||
if not value:
|
||||
print("(o_o) Usage: /pet scale <factor> (e.g. /pet scale 0.5)")
|
||||
return
|
||||
scale, err = set_pet_scale(value)
|
||||
print(f"(x_x) {err}" if err else f"(^_^) Pet scale → {scale:g}.")
|
||||
return
|
||||
|
||||
if low == "off":
|
||||
_set_enabled(False)
|
||||
print("(-_-)zzZ Pet put away.")
|
||||
return
|
||||
|
||||
print(f"(o_o) Fetching '{arg}' from petdex…")
|
||||
try:
|
||||
pet = store.install_pet(arg)
|
||||
except (store.PetStoreError, ManifestError) as exc:
|
||||
print(f"(x_x) Couldn't adopt '{arg}': {exc}")
|
||||
return
|
||||
_set_active(arg)
|
||||
print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.")
|
||||
|
||||
def _handle_cron_command(self, cmd: str):
|
||||
"""Handle the /cron command to manage scheduled tasks."""
|
||||
from cli import get_job
|
||||
|
||||
@@ -176,6 +176,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
subcommands=("pending", "approve", "reject", "approval")),
|
||||
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
||||
"Tools & Skills"),
|
||||
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
|
||||
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
|
||||
@@ -1104,6 +1104,11 @@ DEFAULT_CONFIG = {
|
||||
"min_interval_hours": 24,
|
||||
},
|
||||
|
||||
# Maximum characters loaded from a single automatic context file such as
|
||||
# SOUL.md, AGENTS.md, CLAUDE.md, .hermes.md, or .cursorrules before Hermes
|
||||
# applies head/tail truncation. This is separate from read_file tool limits.
|
||||
"context_file_max_chars": 20_000,
|
||||
|
||||
# Maximum characters returned by a single read_file call. Reads that
|
||||
# exceed this are rejected with guidance to use offset+limit.
|
||||
# 100K chars ≈ 25–35K tokens across typical tokenisers.
|
||||
@@ -1529,6 +1534,31 @@ DEFAULT_CONFIG = {
|
||||
"fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide
|
||||
},
|
||||
"copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled"
|
||||
# Petdex animated mascot (https://github.com/crafter-station/petdex).
|
||||
# A purely cosmetic sprite that reacts to agent activity across the
|
||||
# CLI, TUI, and desktop app. Manage with `hermes pets`. Disabled until
|
||||
# a pet is installed + selected (no effect on prompt caching — this is
|
||||
# a display concern only).
|
||||
"pet": {
|
||||
"enabled": False,
|
||||
# Active pet slug; resolved against installed pets in
|
||||
# get_hermes_home()/pets/. Empty → first installed pet.
|
||||
"slug": "",
|
||||
# Terminal render protocol for CLI/TUI:
|
||||
# auto — detect kitty/iTerm2/sixel, else unicode half-blocks
|
||||
# kitty | iterm | sixel | unicode | off
|
||||
"render_mode": "auto",
|
||||
# Master size scalar (relative to native 192×208 frames). One knob
|
||||
# shrinks every surface: the desktop canvas scales its pixels by it
|
||||
# and the CLI/TUI derive their terminal column width from it. The
|
||||
# half-block fallback clamps to a legibility floor (it can't shrink
|
||||
# as far as true-pixel kitty/GUI without turning to mush).
|
||||
"scale": 0.33,
|
||||
# Hard override for terminal column width. 0 = auto (derive from
|
||||
# scale); set a positive int only to pin the half-block/kitty width
|
||||
# independently of scale.
|
||||
"unicode_cols": 0,
|
||||
},
|
||||
},
|
||||
|
||||
# Web dashboard settings
|
||||
|
||||
@@ -178,6 +178,14 @@ def build_models_payload(
|
||||
user_models.update(m.lower() for m in (row.get("models") or []))
|
||||
if user_models:
|
||||
for row in rows:
|
||||
# A user's own configured provider is never an "aggregator
|
||||
# duplicate" of itself: user_models is built from these very
|
||||
# rows, and is_aggregator() reports True for every custom:*
|
||||
# slug. Without this guard the dedup strips a user-defined
|
||||
# custom provider's entire model list (all of it lives in
|
||||
# user_models), emptying its picker row.
|
||||
if row.get("is_user_defined"):
|
||||
continue
|
||||
slug = row.get("slug", "")
|
||||
if not _is_aggregator(slug):
|
||||
continue
|
||||
|
||||
+139
-401
@@ -1640,286 +1640,8 @@ def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
|
||||
return bundled if bundled.is_file() else None
|
||||
|
||||
|
||||
def _config_tui_engine_early() -> str | None:
|
||||
"""Read ``display.tui_engine`` from config via a minimal YAML read.
|
||||
|
||||
Returns the configured engine string, or ``None`` when unset/unreadable so the
|
||||
caller can apply the availability-gated default. Mirrors
|
||||
:func:`_config_default_interface_early`.
|
||||
"""
|
||||
try:
|
||||
home = os.environ.get("HERMES_HOME")
|
||||
cfg_path = (
|
||||
os.path.join(home, "config.yaml")
|
||||
if home
|
||||
else os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
||||
)
|
||||
if os.path.exists(cfg_path):
|
||||
import yaml as _yaml_eng
|
||||
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
raw = _yaml_eng.safe_load(_f) or {}
|
||||
disp = raw.get("display", {})
|
||||
if isinstance(disp, dict):
|
||||
eng = disp.get("tui_engine")
|
||||
if isinstance(eng, str) and eng.strip():
|
||||
return eng.strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_tui_engine() -> str:
|
||||
"""Which TUI engine to launch: "ink" (default) or "opentui".
|
||||
|
||||
Precedence: ``HERMES_TUI_ENGINE`` env > ``display.tui_engine`` config >
|
||||
(OpenTUI when this host can run it — Node >= 26.3 + the built package — else Ink).
|
||||
The OpenTUI engine runs on Node 26.3+ via the experimental ``node:ffi`` renderer,
|
||||
which is not validated on Windows or Termux — a request for "opentui" there falls
|
||||
back to "ink" with a notice so a stale flag never strands the user on an engine
|
||||
that can't start.
|
||||
"""
|
||||
env = (os.environ.get("HERMES_TUI_ENGINE") or "").strip().lower()
|
||||
# Explicit choice (env > config) wins; otherwise default to OpenTUI when this
|
||||
# host is genuinely set up for it (Node >= 26.3 + the built bundle), else Ink.
|
||||
engine = env or _config_tui_engine_early() or ("opentui" if _opentui_available() else "ink")
|
||||
if engine != "opentui":
|
||||
return "ink"
|
||||
|
||||
# opentui requested — gate on platform support.
|
||||
unsupported = sys.platform.startswith("win") or _is_termux_startup_environment()
|
||||
if unsupported:
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
where = "Windows" if sys.platform.startswith("win") else "Termux"
|
||||
print(
|
||||
f"HERMES_TUI_ENGINE=opentui is not supported on {where} "
|
||||
f"(needs Node 26.3+ with experimental FFI) — falling back to the Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return "ink"
|
||||
return "opentui"
|
||||
|
||||
|
||||
NODE26_MIN_VERSION = (26, 3, 0)
|
||||
|
||||
|
||||
def _node_version_tuple(node_bin: str) -> tuple[int, int, int] | None:
|
||||
"""Return (major, minor, patch) for a node binary, or ``None`` if unreadable."""
|
||||
try:
|
||||
out = subprocess.run([node_bin, "--version"], capture_output=True, text=True, timeout=5)
|
||||
except Exception:
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
raw = (out.stdout or "").strip().lstrip("v").split("-", 1)[0]
|
||||
parts = raw.split(".")
|
||||
try:
|
||||
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _fnm_node26_candidates() -> list[str]:
|
||||
"""Node binaries from fnm's installed versions, newest first.
|
||||
|
||||
fnm keeps each version at ``<FNM_DIR>/node-versions/v<X.Y.Z>/installation/
|
||||
bin/node`` (default ``FNM_DIR``: ``$XDG_DATA_HOME/fnm`` or ``~/.local/share/
|
||||
fnm``; macOS Homebrew also uses ``~/Library/Application Support/fnm``). When
|
||||
the *active* node is older than 26.3 — e.g. the user's fnm default is on
|
||||
v25 — the right 26.x is still installed and usable; surface it so OpenTUI
|
||||
works without the user re-aliasing their global default. Version-sorted so
|
||||
the newest qualifying node wins.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
fnm_dir = os.environ.get("FNM_DIR")
|
||||
if fnm_dir:
|
||||
roots.append(Path(fnm_dir))
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg:
|
||||
roots.append(Path(xdg) / "fnm")
|
||||
roots.append(Path.home() / ".local" / "share" / "fnm")
|
||||
roots.append(Path.home() / "Library" / "Application Support" / "fnm")
|
||||
|
||||
seen: set[Path] = set()
|
||||
found: list[tuple[tuple[int, int, int], str]] = []
|
||||
for root in roots:
|
||||
versions_dir = root / "node-versions"
|
||||
if versions_dir in seen or not versions_dir.is_dir():
|
||||
continue
|
||||
seen.add(versions_dir)
|
||||
try:
|
||||
entries = list(versions_dir.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
node_bin = entry / "installation" / "bin" / "node"
|
||||
if not (node_bin.is_file() and os.access(node_bin, os.X_OK)):
|
||||
continue
|
||||
# Trust the directory name for sorting; the real probe happens in
|
||||
# the caller (a renamed/symlinked dir still gets version-checked).
|
||||
name = entry.name.lstrip("v").split("-", 1)[0]
|
||||
parts = name.split(".")
|
||||
try:
|
||||
ver = (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except (IndexError, ValueError):
|
||||
ver = (0, 0, 0)
|
||||
found.append((ver, str(node_bin)))
|
||||
found.sort(key=lambda pair: pair[0], reverse=True)
|
||||
return [path for _, path in found]
|
||||
|
||||
|
||||
def _node26_bin_or_none() -> str | None:
|
||||
"""Resolve a Node >= 26.3.0 binary (no exit — a probe), or ``None``.
|
||||
|
||||
Order: ``HERMES_NODE`` override > ``node`` on PATH > newest fnm-installed
|
||||
version. Each is gated on the real ``--version`` being >= 26.3.0. OpenTUI's
|
||||
native renderer loads via the experimental ``node:ffi`` API that only exists
|
||||
on Node 26.3+, so an older Node is treated as "not available" — but an
|
||||
installed-yet-inactive 26.x (common when fnm's default is on an older line)
|
||||
is discovered and used so the engine still launches.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
env_node = os.environ.get("HERMES_NODE")
|
||||
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
|
||||
candidates.append(env_node)
|
||||
path = shutil.which("node")
|
||||
if path:
|
||||
candidates.append(path)
|
||||
candidates.extend(_fnm_node26_candidates())
|
||||
for cand in candidates:
|
||||
ver = _node_version_tuple(cand)
|
||||
if ver is not None and ver >= NODE26_MIN_VERSION:
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def _node26_bin() -> str:
|
||||
"""Resolve Node >= 26.3.0 for the OpenTUI engine, or exit with a clear message.
|
||||
|
||||
Use :func:`_node26_bin_or_none` for a non-fatal availability probe.
|
||||
"""
|
||||
node = _node26_bin_or_none()
|
||||
if node is not None:
|
||||
return node
|
||||
print(
|
||||
"Node.js >= 26.3.0 not found — the OpenTUI TUI engine needs it for the "
|
||||
"experimental node:ffi renderer.\n"
|
||||
"Install Node 26.3+ (e.g. via fnm/nvm) or set HERMES_NODE=/path/to/node, "
|
||||
"or unset HERMES_TUI_ENGINE to use the default Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _opentui_npm() -> str:
|
||||
"""Resolve npm (ships with Node) to build the OpenTUI bundle, or exit."""
|
||||
npm = shutil.which("npm")
|
||||
if npm:
|
||||
return npm
|
||||
print(
|
||||
"npm not found — needed to build the OpenTUI engine bundle.\n"
|
||||
"Install Node 26.3+ (it ships npm), or unset HERMES_TUI_ENGINE for Ink.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _opentui_available() -> bool:
|
||||
"""Whether the OpenTUI engine can actually launch on this host.
|
||||
|
||||
True only when the platform is supported (not Windows/Termux), a Node >= 26.3
|
||||
binary resolves (the node:ffi floor), AND the v2 package is BUILT
|
||||
(``dist/main.js``) with its ``node_modules`` installed. This gates the DEFAULT
|
||||
engine: a host genuinely set up for OpenTUI defaults to it; everyone else stays
|
||||
on Ink. An explicit ``HERMES_TUI_ENGINE`` env or ``display.tui_engine`` config
|
||||
choice bypasses this probe (and triggers an on-demand build).
|
||||
"""
|
||||
if sys.platform.startswith("win") or _is_termux_startup_environment():
|
||||
return False
|
||||
if _node26_bin_or_none() is None:
|
||||
return False
|
||||
pkg = PROJECT_ROOT / "ui-opentui"
|
||||
built = pkg / "dist" / "main.js"
|
||||
return built.is_file() and (pkg / "node_modules" / "@opentui").is_dir()
|
||||
|
||||
|
||||
def _make_opentui_argv(tui_dev: bool) -> tuple[list[str], Path]:
|
||||
"""Argv for the native OpenTUI engine under Node 26 (no Bun).
|
||||
|
||||
Builds the Solid + Effect-at-boundary engine (``ui-opentui``) with esbuild
|
||||
(``npm run build`` → ``dist/main.js``) when the bundle is missing (or always, in
|
||||
``--dev``), then launches it on Node with the experimental FFI flag:
|
||||
|
||||
node --experimental-ffi --no-warnings dist/main.js
|
||||
|
||||
``--no-warnings`` keeps the ExperimentalWarning off the TUI's stderr. Returns the
|
||||
argv and the package cwd.
|
||||
|
||||
The spawned ``tui_gateway`` resolves its Python from ``HERMES_PYTHON_SRC_ROOT``
|
||||
(the caller sets it to ``PROJECT_ROOT``); the built bundle's own fallback also
|
||||
walks up to the checkout root, so the gateway resolves correctly either way.
|
||||
"""
|
||||
app_dir = PROJECT_ROOT / "ui-opentui"
|
||||
entry_src = app_dir / "src" / "entry" / "main.tsx"
|
||||
if not entry_src.is_file():
|
||||
print(
|
||||
f"OpenTUI v2 engine entry not found at {entry_src}.\n"
|
||||
f"Unset HERMES_TUI_ENGINE to use the default Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
node = _node26_bin()
|
||||
|
||||
# The esbuild build needs the package's node_modules (esbuild + the @opentui
|
||||
# packages + the native blob). Without them the build/launch dies cryptically.
|
||||
if not (app_dir / "node_modules" / "@opentui").is_dir():
|
||||
print(
|
||||
f"OpenTUI engine dependencies are not installed in {app_dir}.\n"
|
||||
f"Run: (cd {app_dir} && npm install)\n"
|
||||
f"Or unset HERMES_TUI_ENGINE to use the default Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
built = app_dir / "dist" / "main.js"
|
||||
if tui_dev or not built.is_file():
|
||||
npm = _opentui_npm()
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
print("Building the OpenTUI engine…", file=sys.stderr)
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(app_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("OpenTUI engine build failed.", file=sys.stderr)
|
||||
if preview:
|
||||
print(preview, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --expose-gc (parity with Ink, main.py ~1909): makes `global.gc()` a real
|
||||
# callable so the OpenTUI engine's GC hooks (W2 proactive idle GC; /heapdump)
|
||||
# work instead of being silent no-ops. MUST be an argv flag — Node rejects
|
||||
# --expose-gc in NODE_OPTIONS (see the heap-cap injection below).
|
||||
return [node, "--experimental-ffi", "--no-warnings", "--expose-gc", str(built)], app_dir
|
||||
|
||||
|
||||
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).
|
||||
|
||||
Dual-engine: when ``HERMES_TUI_ENGINE``/``display.tui_engine`` selects the
|
||||
native OpenTUI engine, dispatch to ``_make_opentui_argv`` (Node 26 + its own
|
||||
esbuild build) BEFORE the Ink Node bootstrap — the OpenTUI engine resolves its
|
||||
own Node >= 26.3 and builds its own bundle, so it must not be routed through
|
||||
``_ensure_tui_node`` / the Ink prebuilt-dir logic.
|
||||
"""
|
||||
if _resolve_tui_engine() == "opentui":
|
||||
return _make_opentui_argv(tui_dev)
|
||||
|
||||
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
|
||||
_ensure_tui_node()
|
||||
|
||||
def _node_bin(bin: str) -> str:
|
||||
@@ -2155,57 +1877,6 @@ def _read_cgroup_memory_limit() -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _config_tui_heap_mb_early() -> int | None:
|
||||
"""Read ``display.tui_heap_mb`` from config via a minimal YAML read.
|
||||
|
||||
Returns the configured V8 heap cap in MB, or ``None`` when unset/unreadable.
|
||||
Mirrors :func:`_config_tui_engine_early`. A non-secret behavioral setting, so
|
||||
it lives in ``config.yaml`` (NOT a ``HERMES_*`` env / the NODE_OPTIONS bridge,
|
||||
which is denylisted) — the ``HERMES_TUI_HEAP_MB`` env is only the per-launch
|
||||
override on top of this.
|
||||
"""
|
||||
try:
|
||||
home = os.environ.get("HERMES_HOME")
|
||||
cfg_path = (
|
||||
os.path.join(home, "config.yaml")
|
||||
if home
|
||||
else os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
||||
)
|
||||
if os.path.exists(cfg_path):
|
||||
import yaml as _yaml_heap
|
||||
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
raw = _yaml_heap.safe_load(_f) or {}
|
||||
disp = raw.get("display", {})
|
||||
if isinstance(disp, dict):
|
||||
val = disp.get("tui_heap_mb")
|
||||
if isinstance(val, bool): # guard: YAML true/false is an int subclass
|
||||
return None
|
||||
if isinstance(val, int) and val > 0:
|
||||
return val
|
||||
if isinstance(val, str) and val.strip().isdigit():
|
||||
n = int(val.strip())
|
||||
if n > 0:
|
||||
return n
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_tui_heap_override() -> int | None:
|
||||
"""The user's explicit V8 heap cap (MB), or ``None`` for the default path.
|
||||
|
||||
Precedence: ``HERMES_TUI_HEAP_MB`` env > ``display.tui_heap_mb`` config
|
||||
(matches the ``HERMES_TUI_ENGINE`` env-first pattern). Honored by BOTH engines
|
||||
via the shared ``NODE_OPTIONS`` injection. A positive integer wins; anything
|
||||
else (unset/garbage/non-positive) falls through to the cgroup-aware default.
|
||||
"""
|
||||
env_val = os.environ.get("HERMES_TUI_HEAP_MB", "").strip()
|
||||
if env_val.isdigit() and int(env_val) > 0:
|
||||
return int(env_val)
|
||||
return _config_tui_heap_mb_early()
|
||||
|
||||
|
||||
def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
||||
"""Pick a V8 ``--max-old-space-size`` (MB) that fits the container.
|
||||
|
||||
@@ -2214,16 +1885,7 @@ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
||||
cgroup limit so the heap + non-heap RSS stays under the cgroup ceiling,
|
||||
clamped to a sane floor (1536MB — below this V8 GC-thrashes and the TUI
|
||||
is barely usable). Never exceeds ``default_mb``.
|
||||
|
||||
An explicit ``HERMES_TUI_HEAP_MB`` env / ``display.tui_heap_mb`` config
|
||||
override REPLACES the 8192 default (D3): setting it low is the low-mem opt-in,
|
||||
setting it high raises the ceiling. The cgroup-fit clamp still applies on top
|
||||
so an override never exceeds what the container can hold — a low override is
|
||||
honored as-is, a too-high one is still trimmed to ~75% of the cgroup limit.
|
||||
"""
|
||||
override = _resolve_tui_heap_override()
|
||||
if override is not None:
|
||||
default_mb = override
|
||||
limit = _read_cgroup_memory_limit()
|
||||
if not limit:
|
||||
return default_mb
|
||||
@@ -2240,8 +1902,7 @@ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
||||
|
||||
|
||||
def _launch_tui(
|
||||
# str session id, the bare-`--resume` picker sentinel True, or None.
|
||||
resume_session_id: "Optional[str | bool]" = None,
|
||||
resume_session_id: Optional[str] = None,
|
||||
tui_dev: bool = False,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
@@ -2260,14 +1921,6 @@ def _launch_tui(
|
||||
"""Replace current process with the TUI."""
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
|
||||
# Bare `--resume` arrives as the argparse sentinel True: open the TUI
|
||||
# resume picker instead of resuming a specific session id. Normalize it
|
||||
# here so everything downstream (exit summary, env forwarding) keeps
|
||||
# seeing either a real session id string or None.
|
||||
resume_picker = resume_session_id is True
|
||||
if resume_picker:
|
||||
resume_session_id = None
|
||||
|
||||
import tempfile
|
||||
|
||||
env = os.environ.copy()
|
||||
@@ -2281,31 +1934,11 @@ def _launch_tui(
|
||||
)
|
||||
os.close(active_session_fd)
|
||||
env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file
|
||||
# Tree-sitter grammar cache for the OpenTUI engine: grammars are fetched
|
||||
# from GitHub on first use and cached here (profile-aware). Unset → OpenTUI
|
||||
# falls back to its XDG default ($XDG_DATA_HOME/opentui). See
|
||||
# ui-opentui/src/boundary/parsers.ts.
|
||||
try:
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
env["HERMES_TUI_PARSER_CACHE"] = str(
|
||||
get_hermes_home() / "cache" / "opentui-parsers"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve OpenTUI parser cache dir", exc_info=True)
|
||||
env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get(
|
||||
"HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT)
|
||||
)
|
||||
env.setdefault("HERMES_PYTHON", sys.executable)
|
||||
env.setdefault("HERMES_CWD", os.getcwd())
|
||||
# The TUI subprocess is launched with cwd=<engine package dir> (so its
|
||||
# build/resolution works), which means the gateway it spawns would otherwise
|
||||
# auto-detect THAT dir as the workspace (chrome bar showed "ui-opentui" no
|
||||
# matter where you ran hermes). TERMINAL_CWD is the gateway's canonical
|
||||
# launch-dir channel (_completion_cwd) — set it to the real cwd here so the
|
||||
# session, chrome bar, and terminal tool all anchor to where you actually
|
||||
# are. Worktree mode overrides it to the worktree path below.
|
||||
env.setdefault("TERMINAL_CWD", os.getcwd())
|
||||
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
|
||||
|
||||
wt_info = None
|
||||
@@ -2382,11 +2015,6 @@ def _launch_tui(
|
||||
# --expose-gc is *not* added here: Node rejects it in NODE_OPTIONS
|
||||
# ("--expose-gc is not allowed in NODE_OPTIONS") and refuses to start.
|
||||
# It is passed as a direct argv flag in _make_tui_argv() instead.
|
||||
#
|
||||
# Both TUI engines run on Node/V8 now — Ink, and the native OpenTUI engine
|
||||
# (Node 26 + node:ffi). So --max-old-space-size (a V8/Node flag) applies to
|
||||
# both. (Pre-Node-26 the OpenTUI engine ran on Bun/JavaScriptCore, which has
|
||||
# no such flag; that gate is gone now that the engine is Node.)
|
||||
_tokens = env.get("NODE_OPTIONS", "").split()
|
||||
if not any(t.startswith("--max-old-space-size=") for t in _tokens):
|
||||
_tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}")
|
||||
@@ -2399,11 +2027,7 @@ def _launch_tui(
|
||||
# resolved for this invocation; direct `node ui-tui/dist/entry.js` users can
|
||||
# still set HERMES_TUI_RESUME themselves.
|
||||
env.pop("HERMES_TUI_RESUME", None)
|
||||
if resume_picker:
|
||||
# Bare --resume: tell the TUI to open the resume picker before any
|
||||
# session.create (create is lazy, so nothing is wasted).
|
||||
env["HERMES_TUI_RESUME"] = "picker"
|
||||
elif resume_session_id:
|
||||
if resume_session_id:
|
||||
env["HERMES_TUI_RESUME"] = resume_session_id
|
||||
|
||||
argv, cwd = _make_tui_argv(tui_dir, tui_dev)
|
||||
@@ -2512,18 +2136,6 @@ def cmd_chat(args):
|
||||
"""Run interactive chat CLI."""
|
||||
use_tui = _resolve_use_tui(args)
|
||||
|
||||
# Bare `--resume` (argparse sentinel True) opens the TUI resume picker —
|
||||
# `_launch_tui` translates it to HERMES_TUI_RESUME=picker. The classic
|
||||
# REPL has no picker overlay, so point at the equivalents instead of
|
||||
# silently resuming something the user didn't choose.
|
||||
if getattr(args, "resume", None) is True and not use_tui:
|
||||
print("Bare --resume opens the session picker, which requires the TUI.")
|
||||
print(
|
||||
"Use 'hermes --tui --resume', 'hermes --resume <id|title>', "
|
||||
"'hermes -c', or 'hermes sessions browse'."
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Resolve --continue into --resume with the latest session or by name
|
||||
continue_val = getattr(args, "continue_last", None)
|
||||
if continue_val and not getattr(args, "resume", None):
|
||||
@@ -2549,10 +2161,9 @@ def cmd_chat(args):
|
||||
print(f"No previous {kind} session found to continue.")
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve --resume by title if it's not a direct session ID. The bare
|
||||
# picker sentinel (True) is not a name — leave it for _launch_tui.
|
||||
# Resolve --resume by title if it's not a direct session ID
|
||||
resume_val = getattr(args, "resume", None)
|
||||
if resume_val and resume_val is not True:
|
||||
if resume_val:
|
||||
resolved = _resolve_session_by_name_or_id(resume_val)
|
||||
if resolved:
|
||||
args.resume = resolved
|
||||
@@ -5499,6 +5110,90 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]:
|
||||
return removed
|
||||
|
||||
|
||||
def _electron_dist_binary(project_root: Path) -> Path:
|
||||
"""Return the path to the Electron main binary inside ``node_modules``.
|
||||
|
||||
electron-builder reads the binary from ``build.electronDist``
|
||||
(``node_modules/electron/dist``) since #38673, so this is the exact file
|
||||
whose absence makes a pack fail with "The specified electronDist does not
|
||||
exist". The basename differs per OS (the platform Electron is named for the
|
||||
host the build runs on).
|
||||
"""
|
||||
dist = project_root / "node_modules" / "electron" / "dist"
|
||||
if sys.platform == "darwin":
|
||||
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
|
||||
if sys.platform == "win32":
|
||||
return dist / "electron.exe"
|
||||
return dist / "electron"
|
||||
|
||||
|
||||
def _electron_dist_ok(project_root: Path) -> bool:
|
||||
"""True when ``node_modules/electron/dist`` holds a usable Electron binary.
|
||||
|
||||
A directory that exists but is missing the binary (a partial extraction from
|
||||
a corrupt cached zip, or an interrupted postinstall) counts as NOT ok, since
|
||||
that is exactly the shape that makes electron-builder throw on the pinned
|
||||
electronDist.
|
||||
"""
|
||||
try:
|
||||
return _electron_dist_binary(project_root).exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _redownload_electron_dist(
|
||||
project_root: Path,
|
||||
env: dict,
|
||||
*,
|
||||
mirror: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""(Re)populate ``node_modules/electron/dist`` via electron's own downloader.
|
||||
|
||||
Since #38673 the desktop build pins ``build.electronDist`` to
|
||||
``node_modules/electron/dist``, so electron-builder reads the Electron binary
|
||||
straight from there and never downloads it during ``npm run pack``. That dist
|
||||
tree is produced by the ``electron`` package's postinstall (``install.js``)
|
||||
during ``npm ci``. When that download is blocked or throttled (GitHub's
|
||||
release host is unreachable in some regions — #47266), the dist is missing
|
||||
and re-running ``pack`` only re-throws "The specified electronDist does not
|
||||
exist". The mirror fallback therefore has to drive *this* downloader, not
|
||||
another ``pack``.
|
||||
|
||||
No-op (returns True) when the dist binary is already present, so an unrelated
|
||||
build failure doesn't trigger a needless ~200 MB re-download. Otherwise drops
|
||||
any partial dist + version marker (electron's install.js short-circuits when
|
||||
``path.txt`` already matches) and runs the downloader once, optionally via a
|
||||
mirror. Best-effort: never raises. Returns True iff the dist binary exists
|
||||
afterward.
|
||||
"""
|
||||
if _electron_dist_ok(project_root):
|
||||
return True
|
||||
|
||||
electron_dir = project_root / "node_modules" / "electron"
|
||||
installer = electron_dir / "install.js"
|
||||
if not installer.is_file():
|
||||
return False
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
return False
|
||||
|
||||
dist_dir = electron_dir / "dist"
|
||||
shutil.rmtree(dist_dir, ignore_errors=True)
|
||||
try:
|
||||
(electron_dir / "path.txt").unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
dl_env = dict(env)
|
||||
if mirror:
|
||||
dl_env["ELECTRON_MIRROR"] = mirror
|
||||
try:
|
||||
subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False)
|
||||
except OSError:
|
||||
return False
|
||||
return _electron_dist_ok(project_root)
|
||||
|
||||
|
||||
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
|
||||
"""Terminate any running desktop app executing from this build's ``release``
|
||||
dir so a rebuild can replace its (otherwise locked) executable.
|
||||
@@ -5753,8 +5448,18 @@ def cmd_gui(args: argparse.Namespace):
|
||||
# failure was something else, the clean re-download is harmless
|
||||
# and the retry fails the same way.
|
||||
purged = _purge_electron_build_cache(desktop_dir)
|
||||
if purged:
|
||||
print(" ⚠ Desktop build failed; cleared cached Electron download and retrying once...")
|
||||
# electronDist is pinned to node_modules/electron/dist (#38673):
|
||||
# electron-builder reads the Electron binary from there and `pack`
|
||||
# never downloads it, so purging the cache + re-running pack can't
|
||||
# by itself repopulate a missing/partial dist. When the dist is
|
||||
# actually gone, re-run electron's own downloader so the retry has
|
||||
# a binary to read. Gated on the dist check so an unrelated build
|
||||
# failure (tsc/vite) doesn't trigger a pointless ~200 MB refetch.
|
||||
restored = False
|
||||
if not _electron_dist_ok(PROJECT_ROOT):
|
||||
restored = _redownload_electron_dist(PROJECT_ROOT, env)
|
||||
if purged or restored:
|
||||
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
|
||||
for p in purged:
|
||||
print(f" - {p}")
|
||||
# The purge can't remove a win-unpacked tree whose Hermes.exe
|
||||
@@ -5772,12 +5477,25 @@ def cmd_gui(args: argparse.Namespace):
|
||||
# trade-off we only make AFTER the canonical GitHub download has
|
||||
# failed, and we never override a user-pinned ELECTRON_MIRROR.
|
||||
print(" ⚠ Desktop build still failing; the Electron download from "
|
||||
"GitHub looks blocked. Retrying once via a public mirror "
|
||||
"GitHub looks blocked. Re-downloading via a public mirror "
|
||||
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
|
||||
mirror = "https://npmmirror.com/mirrors/electron/"
|
||||
mirror_env = dict(env)
|
||||
mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/"
|
||||
_stop_desktop_processes_locking_build(desktop_dir)
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
|
||||
mirror_env["ELECTRON_MIRROR"] = mirror
|
||||
# electronDist is pinned (#38673), so `npm run pack` never
|
||||
# downloads Electron — the mirror only helps if it drives
|
||||
# electron's own downloader. Re-fetch the binary through the
|
||||
# mirror first; otherwise the retry just re-reads the same missing
|
||||
# dist and re-throws "electronDist does not exist" (#47266).
|
||||
have_dist = _electron_dist_ok(PROJECT_ROOT)
|
||||
if not have_dist:
|
||||
have_dist = _redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror)
|
||||
if have_dist:
|
||||
_stop_desktop_processes_locking_build(desktop_dir)
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
|
||||
else:
|
||||
print(" ✗ Could not re-download Electron from the mirror "
|
||||
"(node_modules/electron/dist still missing)")
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
@@ -11347,7 +11065,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"config", "cron", "curator", "dashboard", "debug", "doctor",
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"prompt-size",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
@@ -12237,6 +11955,26 @@ def main():
|
||||
except Exception as _exc:
|
||||
logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc)
|
||||
|
||||
# =========================================================================
|
||||
# pets command — petdex animated mascots (CLI / TUI / desktop display)
|
||||
# =========================================================================
|
||||
pets_parser = subparsers.add_parser(
|
||||
"pets",
|
||||
help="Browse, install, and select petdex animated pets",
|
||||
description=(
|
||||
"Petdex (https://github.com/crafter-station/petdex) is a public "
|
||||
"gallery of animated sprite pets for coding agents. Install one "
|
||||
"and Hermes shows it reacting to agent activity across the CLI, "
|
||||
"TUI, and desktop app."
|
||||
),
|
||||
)
|
||||
try:
|
||||
from hermes_cli.pets import register_cli as _register_pets_cli
|
||||
|
||||
_register_pets_cli(pets_parser)
|
||||
except Exception as _exc:
|
||||
logging.getLogger(__name__).debug("pets CLI wiring failed: %s", _exc)
|
||||
|
||||
# =========================================================================
|
||||
# memory command (parser built in hermes_cli/subcommands/memory.py)
|
||||
# =========================================================================
|
||||
|
||||
@@ -517,7 +517,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
pass
|
||||
|
||||
models = list(_PROVIDER_MODELS.get("xai-oauth") or _PROVIDER_MODELS.get("xai") or [])
|
||||
selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-4.3"))
|
||||
selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-build-0.1"))
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("xai-oauth", base_url)
|
||||
|
||||
@@ -1735,10 +1735,15 @@ def list_authenticated_providers(
|
||||
if fb:
|
||||
models_list = list(fb)
|
||||
|
||||
# Prefer the endpoint's live /models list when credentials are
|
||||
# available, unless the provider explicitly opts out via
|
||||
# discover_models: false (e.g. dedicated endpoints that expose
|
||||
# the entire aggregator catalog via /models).
|
||||
# Prefer the endpoint's live /models list when discoverable,
|
||||
# unless the provider explicitly opts out via discover_models: false.
|
||||
# Policy mirrors Section 4's should_probe logic:
|
||||
# - With an api_key: always probe (user opted into the endpoint).
|
||||
# - Without an api_key but with explicit models: skip — the user
|
||||
# is narrowing a public endpoint to a specific subset.
|
||||
# - Without an api_key AND no explicit models: probe anyway so
|
||||
# bare-endpoint providers (local llama.cpp / Ollama servers)
|
||||
# still show their full model catalog.
|
||||
api_key = str(ep_cfg.get("api_key", "") or "").strip()
|
||||
if not api_key:
|
||||
key_env = str(ep_cfg.get("key_env", "") or "").strip()
|
||||
@@ -1746,7 +1751,11 @@ def list_authenticated_providers(
|
||||
discover = ep_cfg.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in {"false", "no", "0"}
|
||||
if api_url and api_key and discover:
|
||||
has_explicit_models = bool(models_list)
|
||||
should_probe = bool(api_url) and discover and (
|
||||
bool(api_key) or not has_explicit_models
|
||||
)
|
||||
if should_probe:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
live_models = fetch_api_models(api_key, api_url)
|
||||
|
||||
+32
-4
@@ -61,6 +61,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
# MiniMax
|
||||
("minimax/minimax-m3", ""),
|
||||
# Z-AI
|
||||
("z-ai/glm-5.2", ""),
|
||||
("z-ai/glm-5.1", ""),
|
||||
# Xiaomi
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
@@ -109,6 +110,7 @@ def _codex_curated_models() -> list[str]:
|
||||
# (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},
|
||||
# grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3).
|
||||
_XAI_STATIC_FALLBACK: list[str] = [
|
||||
"grok-build-0.1",
|
||||
"grok-4.3",
|
||||
"grok-4.20-0309-reasoning",
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
@@ -116,7 +118,7 @@ _XAI_STATIC_FALLBACK: list[str] = [
|
||||
]
|
||||
|
||||
|
||||
_XAI_TOP_MODEL = "grok-4.3"
|
||||
_XAI_TOP_MODEL = "grok-build-0.1"
|
||||
|
||||
|
||||
def _xai_promote_top(ids: list[str]) -> list[str]:
|
||||
@@ -182,6 +184,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
# MiniMax
|
||||
"minimax/minimax-m3",
|
||||
# Z-AI
|
||||
"z-ai/glm-5.2",
|
||||
"z-ai/glm-5.1",
|
||||
# Xiaomi
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
@@ -2368,10 +2371,17 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
if not base_url:
|
||||
base_url = _p.base_url
|
||||
if api_key:
|
||||
live = _p.fetch_models(api_key=api_key)
|
||||
live = _p.fetch_models(api_key=api_key, base_url=base_url or None)
|
||||
if live:
|
||||
if normalized in {"kimi-coding", "kimi-coding-cn"}:
|
||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
# Merge static curated list with live API results so
|
||||
# models that the live endpoint omits (stale cache,
|
||||
# partial rollout) still appear in the picker.
|
||||
# Curated entries come first so deliberately-surfaced
|
||||
# newest models (e.g. kimi-k2.7-code, #46309) stay at
|
||||
# the top of the picker; live-only entries are appended
|
||||
# afterwards for discovery. (#46850)
|
||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
if curated:
|
||||
merged = list(curated)
|
||||
merged_lower = {m.lower() for m in curated}
|
||||
for m in live:
|
||||
@@ -3934,6 +3944,24 @@ def validate_requested_model(
|
||||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||
|
||||
# Model not in live /v1/models — check the curated catalog
|
||||
# before rejecting. Providers may omit models from their live
|
||||
# listing that are still valid (stale cache, partial rollout,
|
||||
# gated previews). Use the pure-catalog helper (no extra live
|
||||
# fetch) so we only accept models Hermes actually ships. (#46850)
|
||||
if _model_in_provider_catalog(
|
||||
requested_for_lookup.lower(), _provider_keys(normalized)
|
||||
):
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": (
|
||||
f"Note: `{requested}` was not found in the live /v1/models listing "
|
||||
f"but exists in the curated catalog — accepted."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
"""CLI subcommand: ``hermes pets <subcommand>``.
|
||||
|
||||
Thin shell around :mod:`agent.pet`. Browses the public petdex gallery,
|
||||
installs pets into the profile's ``pets/`` directory, selects the active
|
||||
mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check.
|
||||
|
||||
No side effects at import time — ``main.py`` wires the argparse subparsers on
|
||||
demand via :func:`register_cli`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def _print(msg: str = "") -> None:
|
||||
print(msg)
|
||||
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_list(args) -> int:
|
||||
"""List gallery pets (or only installed ones with ``--installed``)."""
|
||||
from agent.pet import store
|
||||
|
||||
if getattr(args, "installed", False):
|
||||
pets = store.installed_pets()
|
||||
if not pets:
|
||||
_print("No pets installed. Try: hermes pets install boba")
|
||||
return 0
|
||||
_print(f"Installed pets ({len(pets)}):")
|
||||
for pet in pets:
|
||||
_print(f" {pet.slug:<24} {pet.display_name}")
|
||||
return 0
|
||||
|
||||
from agent.pet.manifest import ManifestError, fetch_manifest
|
||||
|
||||
try:
|
||||
entries = fetch_manifest()
|
||||
except ManifestError as exc:
|
||||
_err(f"✗ {exc}")
|
||||
return 1
|
||||
|
||||
query = (getattr(args, "query", "") or "").strip().lower()
|
||||
if query:
|
||||
entries = [
|
||||
e
|
||||
for e in entries
|
||||
if query in e.slug.lower() or query in e.display_name.lower()
|
||||
]
|
||||
|
||||
limit = getattr(args, "limit", 0) or 0
|
||||
shown = entries[:limit] if limit > 0 else entries
|
||||
installed = {p.slug for p in store.installed_pets()}
|
||||
|
||||
_print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:")
|
||||
for entry in shown:
|
||||
mark = "✓" if entry.slug in installed else " "
|
||||
_print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})")
|
||||
if limit and len(entries) > limit:
|
||||
_print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)")
|
||||
_print("\nInstall one with: hermes pets install <slug>")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_install(args) -> int:
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError
|
||||
|
||||
slug = args.slug.strip()
|
||||
try:
|
||||
pet = store.install_pet(slug, force=getattr(args, "force", False))
|
||||
except (store.PetStoreError, ManifestError) as exc:
|
||||
_err(f"✗ install failed: {exc}")
|
||||
return 1
|
||||
|
||||
_print(f"✓ installed {pet.display_name} → {pet.directory}")
|
||||
|
||||
if getattr(args, "select", False) or not _has_active_pet():
|
||||
_set_active(slug)
|
||||
_print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)")
|
||||
else:
|
||||
_print(f" Make it active with: hermes pets select {slug}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_remove(args) -> int:
|
||||
from agent.pet import store
|
||||
|
||||
slug = args.slug.strip()
|
||||
if store.remove_pet(slug):
|
||||
_print(f"✓ removed {slug}")
|
||||
return 0
|
||||
_err(f"✗ '{slug}' is not installed")
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_select(args) -> int:
|
||||
from agent.pet import store
|
||||
|
||||
slug = (getattr(args, "slug", "") or "").strip()
|
||||
if not slug:
|
||||
pets = store.installed_pets()
|
||||
if not pets:
|
||||
_err("✗ no pets installed — run: hermes pets install boba")
|
||||
return 1
|
||||
slug = _interactive_pick(pets)
|
||||
if not slug:
|
||||
return 1
|
||||
|
||||
pet = store.load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
_err(f"✗ '{slug}' is not installed — run: hermes pets install {slug}")
|
||||
return 1
|
||||
|
||||
_set_active(slug)
|
||||
_print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_off(args) -> int:
|
||||
_set_enabled(False)
|
||||
_print("✓ pet disabled (display.pet.enabled=false)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_scale(args) -> int:
|
||||
"""Persist ``display.pet.scale`` — one knob resizes every surface."""
|
||||
scale, err = set_pet_scale(args.factor)
|
||||
if err:
|
||||
_err(f"✗ {err}")
|
||||
return 1
|
||||
_print(f"✓ pet scale set to {scale:g} (display.pet.scale)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_show(args) -> int:
|
||||
"""Animate the active (or named) pet in the terminal.
|
||||
|
||||
Uses the shared :class:`~agent.pet.render.PetRenderer` — full graphics
|
||||
protocol (kitty/iTerm2/sixel) when the terminal supports it, else a
|
||||
truecolor Unicode half-block fallback. Ctrl+C to stop.
|
||||
"""
|
||||
import time
|
||||
|
||||
from agent.pet import store
|
||||
from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols
|
||||
from agent.pet.render import build_renderer
|
||||
|
||||
cfg = _pet_config()
|
||||
slug = (getattr(args, "slug", "") or "").strip() or str(cfg.get("slug", "") or "")
|
||||
pet = store.resolve_active_pet(slug)
|
||||
if pet is None:
|
||||
_err("✗ no pet to show — run: hermes pets install boba")
|
||||
return 1
|
||||
|
||||
mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto")
|
||||
scale = float(getattr(args, "scale", 0) or cfg.get("scale", DEFAULT_SCALE) or DEFAULT_SCALE)
|
||||
cols = resolve_cols(scale, cfg.get("unicode_cols", 0))
|
||||
|
||||
renderer = build_renderer(
|
||||
pet.spritesheet,
|
||||
configured_mode=mode_cfg,
|
||||
scale=scale,
|
||||
unicode_cols=cols,
|
||||
)
|
||||
if not renderer.available:
|
||||
_err(
|
||||
"✗ cannot render here (no TTY / graphics disabled). "
|
||||
f"Effective mode: {renderer.mode}."
|
||||
)
|
||||
return 1
|
||||
|
||||
# Which states to play: one named state, or cycle the driveable rows.
|
||||
requested = (getattr(args, "state", "") or "").strip().lower()
|
||||
if requested:
|
||||
states = [requested]
|
||||
elif getattr(args, "cycle", False):
|
||||
states = [s for s in STATE_ROWS if s in {e.value for e in PetState}]
|
||||
else:
|
||||
states = [PetState.IDLE.value]
|
||||
|
||||
is_unicode = renderer.mode == "unicode"
|
||||
frame_delay = max(0.05, (LOOP_MS / 1000.0) / max(1, renderer.frame_count(states[0]) or 1))
|
||||
|
||||
# Right-align the sprite against the terminal's right edge — half-blocks by
|
||||
# indenting each row, graphics protocols by padding the cursor to the right
|
||||
# column before the image draws (kitty/iTerm/sixel all render at the cursor).
|
||||
import shutil
|
||||
|
||||
term_cols = shutil.get_terminal_size((80, 24)).columns
|
||||
indent = ""
|
||||
g_indent = ""
|
||||
if is_unicode:
|
||||
indent = " " * max(0, term_cols - cols - 1)
|
||||
else:
|
||||
cell_cols = max(1, int(renderer.frame_w * renderer.scale) // 8)
|
||||
g_indent = " " * max(0, term_cols - cell_cols - 1)
|
||||
|
||||
out = sys.stdout
|
||||
out.write("\x1b[?25l") # hide cursor
|
||||
out.flush()
|
||||
prev_lines = 0
|
||||
try:
|
||||
_print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)")
|
||||
loops = 0
|
||||
while True:
|
||||
for state in states:
|
||||
count = renderer.frame_count(state) or 1
|
||||
for i in range(count):
|
||||
encoded = renderer.frame(state, i)
|
||||
if is_unicode:
|
||||
if indent:
|
||||
encoded = "\n".join(indent + ln for ln in encoded.split("\n"))
|
||||
if prev_lines:
|
||||
out.write(f"\x1b[{prev_lines}F") # cursor up to redraw in place
|
||||
out.write(encoded)
|
||||
out.write("\x1b[0m\n")
|
||||
# Lines drawn = sprite rows + the trailing newline; move
|
||||
# back up exactly that many so the next frame overwrites.
|
||||
prev_lines = encoded.count("\n") + 1
|
||||
else:
|
||||
out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols
|
||||
out.write(f"{pet.display_name} [{state}]\n")
|
||||
if g_indent:
|
||||
out.write(g_indent)
|
||||
out.write(encoded)
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
time.sleep(frame_delay)
|
||||
loops += 1
|
||||
if getattr(args, "once", False) and loops >= len(states):
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
out.write("\x1b[?25h") # show cursor
|
||||
out.write("\x1b[0m\n")
|
||||
out.flush()
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_doctor(args) -> int:
|
||||
"""Report install state, active pet, config, and terminal capability."""
|
||||
from agent.pet import store
|
||||
from agent.pet.render import detect_terminal_graphics, resolve_mode
|
||||
|
||||
cfg = _pet_config()
|
||||
enabled = bool(cfg.get("enabled"))
|
||||
configured_slug = str(cfg.get("slug", "") or "")
|
||||
mode_cfg = str(cfg.get("render_mode", "auto") or "auto")
|
||||
|
||||
pets = store.installed_pets()
|
||||
active = store.resolve_active_pet(configured_slug)
|
||||
|
||||
_print("petdex doctor")
|
||||
_print(f" pets dir: {store.pets_dir()}")
|
||||
_print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})")
|
||||
_print(f" display.pet.enabled: {enabled}")
|
||||
_print(f" display.pet.slug: {configured_slug or '(unset)'}")
|
||||
_print(f" active (resolved): {active.slug if active else '(none)'}")
|
||||
_print(f" display.pet.render_mode: {mode_cfg}")
|
||||
_print(f" detected graphics: {detect_terminal_graphics()}")
|
||||
_print(f" effective mode (TTY): {resolve_mode(mode_cfg)}")
|
||||
|
||||
ok = True
|
||||
if not pets:
|
||||
_print(" → no pets installed. Run: hermes pets install boba")
|
||||
ok = False
|
||||
elif active is None:
|
||||
_print(" → active pet unresolved. Run: hermes pets select <slug>")
|
||||
ok = False
|
||||
elif not enabled:
|
||||
_print(" → pet display is disabled. Run: hermes pets select " + active.slug)
|
||||
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
_print(" ✗ Pillow not importable — sprite decoding will be unavailable")
|
||||
ok = False
|
||||
|
||||
_print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)")
|
||||
return 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# config helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pet_config() -> dict:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet = display.get("pet", {})
|
||||
return pet if isinstance(pet, dict) else {}
|
||||
|
||||
|
||||
def _has_active_pet() -> bool:
|
||||
return bool(_pet_config().get("enabled")) and bool(_pet_config().get("slug"))
|
||||
|
||||
|
||||
def _set_active(slug: str) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["slug"] = slug
|
||||
pet["enabled"] = True
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _set_enabled(enabled: bool) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["enabled"] = enabled
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _set_scale(scale: float) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["scale"] = scale
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def set_pet_scale(value: float | str) -> tuple[float, str | None]:
|
||||
"""Set ``display.pet.scale`` (clamped to bounds). Returns ``(applied, error)``.
|
||||
|
||||
The single write path behind ``/pet scale`` and the desktop slider, so every
|
||||
surface that resolves scale from config picks it up identically. *error* is
|
||||
set (and nothing written) only when *value* isn't a number.
|
||||
"""
|
||||
from agent.pet.constants import clamp_scale
|
||||
|
||||
try:
|
||||
scale = clamp_scale(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0, f"not a number: {value!r} — try a value like 0.5"
|
||||
|
||||
_set_scale(scale)
|
||||
return scale, None
|
||||
|
||||
|
||||
def toggle_pet_display() -> tuple[bool, str | None, str | None]:
|
||||
"""Toggle ``display.pet.enabled``.
|
||||
|
||||
Returns ``(enabled, display_name, error_message)``. *error_message* is set
|
||||
when turning on but nothing is installed to show.
|
||||
"""
|
||||
from agent.pet import store
|
||||
|
||||
cfg = _pet_config()
|
||||
slug = str(cfg.get("slug", "") or "")
|
||||
pet = store.resolve_active_pet(slug)
|
||||
|
||||
if bool(cfg.get("enabled")):
|
||||
_set_enabled(False)
|
||||
return False, pet.display_name if pet else None, None
|
||||
|
||||
if pet is None:
|
||||
installed = store.installed_pets()
|
||||
if not installed:
|
||||
return False, None, "no pets installed — /pet list to browse, or /pet <slug> to adopt"
|
||||
pet = installed[0]
|
||||
_set_active(pet.slug)
|
||||
else:
|
||||
_set_enabled(True)
|
||||
return True, pet.display_name, None
|
||||
|
||||
|
||||
def print_pet_gallery(*, limit: int = 20) -> None:
|
||||
"""Print a slice of the public petdex gallery (CLI/TUI text fallback)."""
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError, fetch_manifest
|
||||
|
||||
try:
|
||||
entries = fetch_manifest()
|
||||
except ManifestError as exc:
|
||||
print(f"(._.) Couldn't reach the petdex gallery: {exc}")
|
||||
return
|
||||
|
||||
installed = {p.slug for p in store.installed_pets()}
|
||||
shown = entries[:limit] if limit > 0 else entries
|
||||
print(f"(^o^)/ petdex gallery — first {len(shown)} of {len(entries)}:")
|
||||
for entry in shown:
|
||||
mark = "●" if entry.slug in installed else "○"
|
||||
print(f" {mark} {entry.slug:<24} {entry.display_name}")
|
||||
print(" /pet <slug> to adopt · /pet to toggle")
|
||||
|
||||
|
||||
def _clear_active_if(slug: str) -> bool:
|
||||
"""Disable + unset the active pet iff it's ``slug`` (e.g. after removal).
|
||||
|
||||
Returns whether anything changed, so callers don't write config needlessly.
|
||||
"""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
pet = cfg.setdefault("display", {}).setdefault("pet", {})
|
||||
if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != slug:
|
||||
return False
|
||||
pet["slug"] = ""
|
||||
pet["enabled"] = False
|
||||
save_config(cfg)
|
||||
return True
|
||||
|
||||
|
||||
def _interactive_pick(pets) -> str:
|
||||
"""Minimal numbered picker (avoids curses dep for a tiny list)."""
|
||||
_print("Installed pets:")
|
||||
for i, pet in enumerate(pets, 1):
|
||||
_print(f" {i}. {pet.slug:<24} {pet.display_name}")
|
||||
try:
|
||||
choice = input("Select a pet [1]: ").strip() or "1"
|
||||
idx = int(choice) - 1
|
||||
except (EOFError, KeyboardInterrupt, ValueError):
|
||||
_err("✗ cancelled")
|
||||
return ""
|
||||
if 0 <= idx < len(pets):
|
||||
return pets[idx].slug
|
||||
_err("✗ invalid selection")
|
||||
return ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# argparse wiring
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
"""Attach ``pets`` subcommands to *parent* (called by main.py)."""
|
||||
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
|
||||
subs = parent.add_subparsers(dest="pets_command")
|
||||
|
||||
p_list = subs.add_parser("list", help="Browse the petdex gallery")
|
||||
p_list.add_argument("query", nargs="?", default="", help="Filter by slug/name substring")
|
||||
p_list.add_argument("--installed", action="store_true", help="Only show installed pets")
|
||||
p_list.add_argument("--limit", type=int, default=40, help="Max rows (0 = all)")
|
||||
p_list.set_defaults(func=_cmd_list)
|
||||
|
||||
p_install = subs.add_parser("install", help="Install a pet from the gallery")
|
||||
p_install.add_argument("slug", help="Pet slug (e.g. boba)")
|
||||
p_install.add_argument("--force", action="store_true", help="Re-download even if present")
|
||||
p_install.add_argument("--select", action="store_true", help="Make it the active pet")
|
||||
p_install.set_defaults(func=_cmd_install)
|
||||
|
||||
p_select = subs.add_parser("select", help="Set the active pet (writes display.pet.*)")
|
||||
p_select.add_argument("slug", nargs="?", default="", help="Pet slug (omit for picker)")
|
||||
p_select.set_defaults(func=_cmd_select)
|
||||
|
||||
p_show = subs.add_parser("show", help="Animate the active pet in the terminal")
|
||||
p_show.add_argument("slug", nargs="?", default="", help="Pet slug (default: active)")
|
||||
p_show.add_argument("--state", default="", help="Single state: idle/run/review/failed/wave/jump")
|
||||
p_show.add_argument("--cycle", action="store_true", help="Cycle through all states")
|
||||
p_show.add_argument("--once", action="store_true", help="Play once instead of looping")
|
||||
p_show.add_argument("--mode", default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)")
|
||||
p_show.add_argument("--scale", type=float, default=0, help="Override scale (0 = config)")
|
||||
p_show.set_defaults(func=_cmd_show)
|
||||
|
||||
subs.add_parser("off", help="Disable the pet display").set_defaults(func=_cmd_off)
|
||||
|
||||
p_scale = subs.add_parser("scale", help="Resize the pet everywhere (display.pet.scale)")
|
||||
p_scale.add_argument("factor", help="Scale factor, e.g. 0.5 (clamped 0.1–3.0)")
|
||||
p_scale.set_defaults(func=_cmd_scale)
|
||||
|
||||
p_remove = subs.add_parser("remove", help="Delete an installed pet")
|
||||
p_remove.add_argument("slug", help="Pet slug")
|
||||
p_remove.set_defaults(func=_cmd_remove)
|
||||
|
||||
subs.add_parser("doctor", help="Check pet setup + terminal graphics support").set_defaults(
|
||||
func=_cmd_doctor
|
||||
)
|
||||
@@ -5228,10 +5228,39 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
||||
return {"logged_in": False}
|
||||
|
||||
|
||||
def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]:
|
||||
"""Shell command that clears an external provider's credentials.
|
||||
|
||||
External providers store their credentials outside Hermes, so the disconnect
|
||||
API deliberately refuses them (we never delete files another CLI owns on the
|
||||
user's behalf via a silent API call). For the ones we know how to clear we
|
||||
instead hand the GUI a command it can *run in the embedded terminal* — the
|
||||
user sees exactly what executes, and Hermes then stops resolving the token.
|
||||
|
||||
Claude Code has no scriptable logout (only the interactive ``/logout``), so
|
||||
we remove the credential the same way logout does: the macOS Keychain entry
|
||||
(``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json``
|
||||
file — the two sources ``read_claude_code_credentials()`` consults. Returns
|
||||
None for providers we can't safely clear (the GUI shows a manual hint).
|
||||
"""
|
||||
if provider.get("flow") != "external":
|
||||
return None
|
||||
if provider.get("id") == "claude-code":
|
||||
rm_file = "rm -f ~/.claude/.credentials.json"
|
||||
if sys.platform == "darwin":
|
||||
return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}'
|
||||
return rm_file
|
||||
return None
|
||||
|
||||
|
||||
def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return the manual disconnect path when the API cannot clear this provider."""
|
||||
if provider.get("flow") == "external":
|
||||
return f"Use `{provider['cli_command']}` or that provider's CLI to remove it."
|
||||
if _oauth_provider_disconnect_command(provider):
|
||||
# The GUI offers a one-click "run in terminal" path; this hint is the
|
||||
# fallback wording for surfaces that only show text.
|
||||
return "Managed outside Hermes — run the disconnect command to remove it."
|
||||
return "Managed by that provider's CLI; remove it there."
|
||||
if status.get("source") == "env_var":
|
||||
return "Remove the API key from Settings → Keys instead."
|
||||
return None
|
||||
@@ -5246,6 +5275,8 @@ async def list_oauth_providers(profile: Optional[str] = None):
|
||||
name human label
|
||||
flow "pkce" | "device_code" | "external" | "loopback"
|
||||
cli_command fallback CLI command for users to run manually
|
||||
disconnect_command shell command that clears an external provider's
|
||||
creds (run in the embedded terminal), else null
|
||||
docs_url external docs/portal link for the "Learn more" link
|
||||
status:
|
||||
logged_in bool — currently has usable creds
|
||||
@@ -5267,6 +5298,7 @@ async def list_oauth_providers(profile: Optional[str] = None):
|
||||
"cli_command": p["cli_command"],
|
||||
"docs_url": p["docs_url"],
|
||||
"disconnect_hint": disconnect_hint,
|
||||
"disconnect_command": _oauth_provider_disconnect_command(p),
|
||||
"disconnectable": disconnect_hint is None,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user