Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
7bdef28b30 chore(actions)(deps): bump docker/setup-buildx-action
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.1.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](8d2750c68a...d7f5e7f509)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 16:32:51 +00:00
382 changed files with 2174 additions and 32822 deletions

View File

@@ -18,8 +18,8 @@ on:
permissions:
contents: read
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
actions: read # needed by osv-scanner (SARIF upload)
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
actions: read # needed by osv-scanner (SARIF upload)
security-events: write # needed by osv-scanner (SARIF upload)
concurrency:
@@ -96,7 +96,7 @@ jobs:
supply-chain:
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
if: needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true'
uses: ./.github/workflows/supply-chain-audit.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}

View File

@@ -64,7 +64,7 @@ jobs:
# required check never hangs.
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# Build once, load into the local daemon for smoke testing. Cached
# to gha with a per-arch scope; the push step below reuses every
@@ -206,7 +206,7 @@ jobs:
# arm64 build runs only on push-to-main and release (see build-amd64).
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
@@ -312,7 +312,7 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0

View File

@@ -290,19 +290,6 @@ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# The published image seals /opt/hermes (root-owned, read-only) so a runtime
# lazy install can't mutate the agent's own venv and brick it. But opt-in
# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
# policy 2026-05-12: one quarantined release must not break every install).
# Redirect those lazy installs to a writable dir on the durable data volume.
# lazy_deps appends this dir to the END of sys.path, so a package installed
# here can only ADD modules — it can never shadow or downgrade a core module,
# so the sealed-venv guarantee holds even with installs re-enabled. The dir
# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
# on the /opt/data volume, so it persists across container recreates / image
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
# `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the

View File

@@ -23,11 +23,6 @@ except ModuleNotFoundError:
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
pass
else:
# Stop a ``utils/``/``proxy/``/``ui/`` package in the launch directory from
# shadowing Hermes's own modules — ``hermes acp`` can be started from any
# cwd, including a project that has same-named packages on its path.
hermes_bootstrap.harden_import_path()
import argparse
import asyncio

View File

@@ -1506,7 +1506,6 @@ def init_agent(
# 3. Check general plugin system (user-installed plugins)
# 4. Fall back to built-in ContextCompressor
_selected_engine = None
_copy_failed = False
_engine_name = "compressor" # default
try:
_ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
@@ -1524,35 +1523,15 @@ def init_agent(
# Try general plugin system as fallback
if _selected_engine is None:
_candidate = None
try:
from hermes_cli.plugins import get_plugin_context_engine
_candidate = get_plugin_context_engine()
if _candidate and _candidate.name == _engine_name:
_selected_engine = _candidate
except Exception:
_candidate = None
if _candidate is not None and _candidate.name == _engine_name:
# Deep-copy the shared plugin singleton so a child agent's
# update_model() can't mutate the parent's compressor (#42449).
# Copy can fail for engines holding uncopyable state (locks, DB
# connections, clients); in that case fall back to the built-in
# compressor with an ACCURATE message rather than silently
# mislabelling it "not found".
import copy
try:
_selected_engine = copy.deepcopy(_candidate)
except Exception as _copy_err:
_copy_failed = True
_ra().logger.warning(
"Context engine '%s' could not be safely copied for this "
"agent (%s) — falling back to built-in compressor. Plugin "
"engines that hold uncopyable state (locks, DB connections) "
"should implement __deepcopy__ to copy only mutable budget "
"state.",
_engine_name, _copy_err,
)
_selected_engine = None
pass
if _selected_engine is None and not _copy_failed:
if _selected_engine is None:
_ra().logger.warning(
"Context engine '%s' not found — falling back to built-in compressor",
_engine_name,
@@ -1642,27 +1621,16 @@ def init_agent(
for t in agent.tools
if isinstance(t, dict)
}
from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema
for _raw_schema in agent.context_compressor.get_tool_schemas():
_schema = _normalize_tool_schema(_raw_schema)
if _schema is None:
# A schema with no resolvable name (e.g. an already-wrapped
# entry) would append a nameless tool that strict providers
# 400 on, disabling the whole toolset (#47707). Skip it.
_ra().logger.warning(
"Context engine returned a tool schema with no resolvable "
"name; skipping to avoid poisoning the request (%r)",
_raw_schema,
)
continue
_tname = _schema["name"]
if _tname in _existing_tool_names:
for _schema in agent.context_compressor.get_tool_schemas():
_tname = _schema.get("name", "")
if _tname and _tname in _existing_tool_names:
continue # already registered via plugin/cache path
_wrapped = {"type": "function", "function": _schema}
agent.tools.append(_wrapped)
agent.valid_tool_names.add(_tname)
agent._context_engine_tool_names.add(_tname)
_existing_tool_names.add(_tname)
if _tname:
agent.valid_tool_names.add(_tname)
agent._context_engine_tool_names.add(_tname)
_existing_tool_names.add(_tname)
# Notify context engine of session start
if hasattr(agent, "context_compressor") and agent.context_compressor:

View File

@@ -1297,15 +1297,7 @@ def run_oauth_setup_token() -> Optional[str]:
# Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS
# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary
# for backward compatibility with existing imports and now points at the live host.
_OAUTH_TOKEN_URLS = [
"https://platform.claude.com/v1/oauth/token",
"https://console.anthropic.com/v1/oauth/token",
]
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
@@ -1403,34 +1395,18 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
"code_verifier": verifier,
}).encode()
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
# console.anthropic.com now 404s. Try the new host first, then fall
# back to console for older deployments (mirrors the refresh path).
result = None
last_error = None
for endpoint in _OAUTH_TOKEN_URLS:
req = urllib.request.Request(
endpoint,
data=exchange_data,
headers={
"Content-Type": "application/json",
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode())
break
except Exception as exc:
last_error = exc
logger.debug("Anthropic token exchange failed at %s: %s", endpoint, exc)
continue
req = urllib.request.Request(
_OAUTH_TOKEN_URL,
data=exchange_data,
headers={
"Content-Type": "application/json",
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
},
method="POST",
)
if result is None:
raise last_error if last_error is not None else ValueError(
"Anthropic token exchange failed"
)
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode())
except Exception as e:
print(f"Token exchange failed: {e}")
return None

View File

@@ -890,15 +890,7 @@ class ContextCompressor(ContextEngine):
# This is independent of the abort_on_summary_failure config flag:
# rotating on a broken credential is never the right behavior.
self._last_summary_auth_failure: bool = False
# Set when summary generation ultimately fails due to a transient
# network/connection error (httpx/httpcore connection drop, premature
# stream close, etc.) — distinct from auth failures but treated the
# same way by compress(): ABORT and preserve the session unchanged
# rather than destroy the middle window for a deterministic
# "summary unavailable" marker. Retrying once the network recovers is
# strictly better than discarding context for a transient blip
# (#29559, #25585). Independent of abort_on_summary_failure.
self._last_summary_network_failure: bool = False
# When a user-configured summary model fails and we recover by
# retrying on the main model, record the failure so gateway /
# CLI callers can still warn the user even though compression
# succeeded. Silent recovery would hide the broken config.
@@ -1695,7 +1687,6 @@ This compaction should PRIORITISE preserving all information related to the focu
self._summary_model_fallen_back = False
self._last_summary_error = None
self._last_summary_auth_failure = False
self._last_summary_network_failure = False
return self._with_summary_prefix(summary)
except Exception as e:
# ``call_llm`` raises ``RuntimeError`` for two very different cases:
@@ -1828,15 +1819,6 @@ This compaction should PRIORITISE preserving all information related to the focu
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
self._last_summary_error = err_text
# A terminal connection/network failure (we reach this branch only
# after any main-model fallback has already been tried or is
# unavailable). Flag it so compress() ABORTS and preserves the
# session unchanged instead of destroying the middle window for a
# placeholder marker — retrying once the network recovers is
# strictly better than dropping context (#29559, #25585). Mirrors
# the auth-failure carve-out; independent of abort_on_summary_failure.
if _is_streaming_closed:
self._last_summary_network_failure = True
logger.warning(
"Failed to generate context summary: %s. "
"Further summary attempts paused for %d seconds.",
@@ -2400,7 +2382,6 @@ This compaction should PRIORITISE preserving all information related to the focu
self._last_aux_model_failure_model = None
self._last_compress_aborted = False
self._last_summary_auth_failure = False
self._last_summary_network_failure = False
# Manual /compress (force=True) bypasses the failure cooldown so the
# user can retry immediately after an auto-compress abort. Without
@@ -2517,21 +2498,15 @@ This compaction should PRIORITISE preserving all information related to the focu
# surface a warning.
# Default is False (historical behavior).
#
# EXCEPTION — auth AND transient network failures always abort. A
# 401/403 from the summary call means the credential or endpoint is
# broken (invalid/blocked key, or a token pointed at the wrong
# inference host). A connection/stream-close error means the network
# blipped at the compaction moment (#29559). In BOTH cases rotating into
# EXCEPTION — auth failures always abort. A 401/403 from the summary
# call means the credential or endpoint is broken (invalid/blocked
# key, or a token pointed at the wrong inference host). Rotating into
# a child session with a placeholder summary on a broken credential
# strands the user on a degraded session for zero benefit — every
# subsequent call fails the same way. So when the failure was an auth
# error we abort regardless of abort_on_summary_failure, preserving
# the conversation unchanged until the credential is fixed.
if not summary and (
self.abort_on_summary_failure
or self._last_summary_auth_failure
or self._last_summary_network_failure
):
if not summary and (self.abort_on_summary_failure or self._last_summary_auth_failure):
n_skipped = compress_end - compress_start
self._last_summary_dropped_count = 0 # nothing actually dropped
self._last_summary_fallback_used = False
@@ -2546,15 +2521,6 @@ This compaction should PRIORITISE preserving all information related to the focu
"with /compress or start fresh with /new.",
n_skipped,
)
elif self._last_summary_network_failure:
logger.warning(
"Summary generation failed with a network/connection "
"error — aborting compression. %d message(s) preserved "
"unchanged; the session was NOT rotated. This is "
"transient: retry with /compress once connectivity "
"recovers, or continue the conversation as-is.",
n_skipped,
)
else:
logger.warning(
"Summary generation failed — aborting compression "

View File

@@ -4050,19 +4050,6 @@ def run_conversation(
messages.append(assistant_msg)
agent._emit_interim_assistant_message(assistant_msg)
try:
# Persist the assistant tool-call turn before any tool
# side effects run. If a destructive tool restarts or
# terminates Hermes mid-turn, resume logic still sees the
# exact tool-call block that already executed.
agent._flush_messages_to_session_db(messages, conversation_history)
except Exception as exc:
logger.warning(
"Incremental tool-call persistence failed before execution "
"(session=%s): %s",
agent.session_id or "none",
exc,
)
# Close any open streaming display (response box, reasoning
# box) before tool execution begins. Intermediate turns may

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env python3
"""``/learn`` — build the standards-guided prompt that turns whatever the user
described into a reusable skill.
``/learn`` is open-ended. The user can point it at anything they can describe:
a directory of code, an API doc URL, a workflow they just walked the agent
through in this conversation, or pasted notes. This module builds ONE prompt
that instructs the live agent to:
1. Gather the sources the user named, using the tools it already has
(``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the
current conversation for "what I just did", the user's text for pasted
material).
2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes
skill-authoring standards (description <=60 chars, the modern section
order, Hermes-tool framing, no invented commands).
There is no separate distillation engine and no model-tool footprint: the
agent does the work with its existing toolset, so this works identically on
local, Docker, and remote terminal backends. Every surface (CLI ``/learn``,
gateway ``/learn``, the dashboard "Learn a skill" panel) calls
:func:`build_learn_prompt` and feeds the result to the agent as a normal turn.
"""
from __future__ import annotations
# The house-style rules, distilled from AGENTS.md "Skill authoring standards
# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in
# the prompt so the agent authors skills the way a maintainer would by hand.
_AUTHORING_STANDARDS = """\
Follow the Hermes skill-authoring standards exactly:
Frontmatter:
- name: lowercase-hyphenated, <=64 chars, no spaces.
- description: ONE sentence, <=60 characters, ends with a period. State the
capability, not the implementation. No marketing words (powerful,
comprehensive, seamless, advanced). Do NOT repeat the skill name. If the
description contains a colon, wrap the whole value in double quotes.
- version: 0.1.0
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
Body section order (omit a section only if it genuinely has no content):
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT
do, and the key dependency stance (e.g. "stdlib only").
2. "## When to Use" — bullet list of concrete trigger phrases.
3. "## Prerequisites" — exact env vars, install steps, credentials.
4. "## How to Run" — the canonical invocation, framed through Hermes tools.
5. "## Quick Reference" — a flat command/endpoint list, no narration.
6. "## Procedure" — numbered steps with copy-paste-exact commands.
7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't.
8. "## Verification" — a single command/check that proves the skill worked.
Hermes-tool framing (this is what makes it a skill, not shell docs):
- Frame running scripts as "invoke through the `terminal` tool".
- Use `read_file` (not cat/head/tail), `search_files` (not grep/find/ls),
`patch` (not sed/awk), `web_extract` (not curl-to-scrape),
`vision_analyze` for images. Reference these tools by name in backticks.
- Do NOT name shell utilities the agent already has wrapped.
Quality bar:
- Prefer exact commands, endpoint URLs, function signatures, and config keys
that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if
you didn't see it in the source, don't write it.
- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a
complex one. Don't re-paste the source docs.
- Don't write a router/index/hub skill that only points at other skills.
- Larger scripts/parsers belong in a `scripts/` file (add via
`skill_manage` write_file), referenced from SKILL.md by relative path — not
inlined for the agent to re-type every run."""
def build_learn_prompt(user_request: str) -> str:
"""Build the agent prompt for an open-ended ``/learn`` request.
Args:
user_request: the free-text the user gave after ``/learn`` — a
description of the workflow, paths, URLs, or "what I just did".
Returns:
A complete instruction the agent runs as a normal turn. The agent
gathers the described sources with its existing tools and authors the
skill via ``skill_manage``.
"""
req = (user_request or "").strip()
if not req:
req = (
"the workflow we just went through in this conversation — review "
"the steps taken and distill them into a reusable skill"
)
return (
"[/learn] The user wants you to learn a reusable skill from the "
"source(s) they described below, and save it.\n\n"
f"WHAT TO LEARN FROM:\n{req}\n\n"
"Do this:\n"
"1. Gather the material. Resolve whatever the user named using the "
"tools you already have — `read_file`/`search_files` for local files "
"or directories, `web_extract` for URLs, the current conversation "
"history if they referred to something you just did, and the text "
"they pasted as-is. If the request is ambiguous about scope, make a "
"reasonable choice and note it; do not stall.\n"
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
"(action=\"create\"). Pick a sensible category. If the procedure needs "
"a non-trivial script, add it under the skill's `scripts/` with "
"`skill_manage` write_file and reference it by relative path.\n\n"
f"{_AUTHORING_STANDARDS}\n\n"
"When done, tell the user the skill name, its category, and a "
"one-line summary of what it captured."
)

View File

@@ -46,39 +46,6 @@ logger = logging.getLogger(__name__)
_SYNC_DRAIN_TIMEOUT_S = 5.0
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
"""Return a function-tool dict with a resolvable top-level ``name``.
Context engines and memory providers expose tool schemas via
``get_tool_schemas()``. The expected shape is a bare function schema
(``{"name": ..., "description": ..., "parameters": ...}``) which callers
wrap as ``{"type": "function", "function": schema}``.
Some providers instead return an entry that is *already* in OpenAI tool
form (``{"type": "function", "function": {"name": ...}}``). Wrapping that
a second time produces ``{"type": "function", "function": {"type":
"function", "function": {...}}}`` whose ``function`` has no top-level
``name``. Strict providers (e.g. DeepSeek) reject the *entire* request
with ``tools[N].function: missing field name`` (HTTP 400), so one bad
schema disables the whole toolset and breaks every turn (#47707).
This helper normalizes both shapes to the bare function schema and
returns ``None`` for anything without a resolvable name, so callers can
skip-with-warning rather than appending a nameless tool.
"""
if not isinstance(schema, dict):
return None
# Unwrap an already-wrapped OpenAI tool entry.
if schema.get("type") == "function" and isinstance(schema.get("function"), dict):
schema = schema["function"]
if not isinstance(schema, dict):
return None
name = schema.get("name", "")
if not name or not isinstance(name, str):
return None
return schema
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if enabled_toolsets is None:
@@ -125,17 +92,11 @@ def inject_memory_provider_tools(agent: Any) -> int:
agent.valid_tool_names = valid_tool_names
added = 0
for raw_schema in get_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
logger.warning(
"Memory provider returned a tool schema with no resolvable "
"name; skipping to avoid poisoning the request (%r)",
raw_schema,
)
for schema in get_schemas():
if not isinstance(schema, dict):
continue
tool_name = schema["name"]
if tool_name in existing_tool_names:
tool_name = schema.get("name", "")
if not tool_name or tool_name in existing_tool_names:
continue
tools.append({"type": "function", "function": schema})
valid_tool_names.add(tool_name)
@@ -409,11 +370,8 @@ class MemoryManager:
_core_tool_names = set(_HERMES_CORE_TOOLS)
# Index tool names → provider for routing
for raw_schema in provider.get_tool_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
continue
tool_name = schema["name"]
for schema in provider.get_tool_schemas():
tool_name = schema.get("name", "")
if tool_name in _core_tool_names:
logger.warning(
"Memory provider '%s' tool '%s' shadows a reserved core "
@@ -700,19 +658,11 @@ class MemoryManager:
seen = set()
for provider in self._providers:
try:
for raw_schema in provider.get_tool_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
logger.warning(
"Memory provider '%s' returned a tool schema with "
"no resolvable name; skipping (%r)",
provider.name, raw_schema,
)
continue
name = schema["name"]
for schema in provider.get_tool_schemas():
name = schema.get("name", "")
if name in _core_tool_names:
continue
if name not in seen:
if name and name not in seen:
schemas.append(schema)
seen.add(name)
except Exception as e:

View File

@@ -1,51 +0,0 @@
"""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",
]

View File

@@ -1,167 +0,0 @@
"""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

View File

@@ -1,29 +0,0 @@
"""Pet generation — base-draft → hatch pipeline.
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
command, and tests:
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
- :class:`HatchResult`, :class:`GenerationError`.
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
Image generation is delegated to the active reference-capable
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
atlas assembly is fully deterministic so it's testable without any API calls.
"""
from __future__ import annotations
from agent.pet.generate.imagegen import GenerationError
from agent.pet.generate.orchestrate import (
HatchResult,
generate_base_drafts,
hatch_pet,
)
__all__ = [
"GenerationError",
"HatchResult",
"generate_base_drafts",
"hatch_pet",
]

View File

@@ -1,781 +0,0 @@
"""Deterministic spritesheet assembly — generated row strips → Hermes atlas.
Image-generation models are good at *drawing* a row of poses but bad at exact
grid geometry, so the model never owns the atlas layout: it produces one loose
horizontal strip per state, and these deterministic ops slice that strip into
clean, centered, transparent ``192x208`` cells and pack them into the sheet our
renderer reads.
The atlas follows the **petdex/Codex standard**: 8 columns x 9 rows of
``192x208`` cells (``1536x1872``), with the row order + per-row frame counts
from OpenAI's ``hatch-pet`` skill. Our renderer (:mod:`agent.pet.render`) keys
frames as ``rows = states, cols = frames`` via
:data:`agent.pet.constants.CODEX_STATE_ROWS`, and a pet built here is a valid
``petdex submit`` spritesheet. Rows shorter than 8 columns leave the trailing
cells fully transparent.
Note ``running`` is the *working* state (in-place processing), NOT locomotion —
``running-right`` / ``running-left`` are the actual directional walk cycles.
The frame-segmentation, fit-to-cell, and transparency-residue logic is adapted
from OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0).
"""
from __future__ import annotations
import io
import logging
import math
from pathlib import Path
from agent.pet.constants import FRAME_H, FRAME_W
logger = logging.getLogger(__name__)
CELL_WIDTH = FRAME_W
CELL_HEIGHT = FRAME_H
# (state, row index, frame count). Order/row indices MUST match
# ``constants.CODEX_STATE_ROWS`` so the renderer crops the right row for each
# driven state, and the per-row frame counts mirror the petdex/Codex
# ``hatch-pet`` ``animation-rows`` spec. The renderer trims trailing blank
# columns, so rows shorter than ``COLUMNS`` (8) just leave the tail transparent.
ROW_SPECS: list[tuple[str, int, int]] = [
("idle", 0, 6),
("running-right", 1, 8),
("running-left", 2, 8),
("waving", 3, 4),
("jumping", 4, 5),
("failed", 5, 8),
("waiting", 6, 6),
("running", 7, 6),
("review", 8, 6),
]
ROWS = len(ROW_SPECS)
COLUMNS = max(count for _, _, count in ROW_SPECS)
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
FRAME_COUNTS: dict[str, int] = {state: count for state, _, count in ROW_SPECS}
# Alpha at/below which a pixel is "background" for component detection.
_ALPHA_FLOOR = 16
# Cell padding kept around a fitted sprite so poses never touch the edge.
_CELL_PAD = 10
# Margin for the normalized pass — small, to fill the cell like real petdex pets
# (they sit ~5px from the edges); the width clamp, not the pad, prevents clipping.
_NORMALIZE_PAD = 14
# Side-lobe cutoff for fitted frames. Adjacent-pose bleed usually appears as a
# small separated horizontal lobe beside the real subject; keep sizeable lobes so
# we don't punish a legitimate wide pose.
_SIDE_LOBE_RATIO = 0.18
# ───────────────────────── background removal ─────────────────────────
def _color_distance(r: int, g: int, b: int, key: tuple[int, int, int]) -> float:
return math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2)
def _has_transparency(image) -> bool:
"""True if the strip already carries a real alpha background."""
extrema = image.getchannel("A").getextrema()
# Min alpha 0 somewhere and a meaningful share of fully-transparent pixels.
if extrema[0] > _ALPHA_FLOOR:
return False
hist = image.getchannel("A").histogram()
transparent = sum(hist[: _ALPHA_FLOOR + 1])
total = image.width * image.height
return transparent > total * 0.05
def _dominant_corner_color(image) -> tuple[int, int, int]:
"""Sample the four corners and return the most common opaque color."""
from collections import Counter
w, h = image.width, image.height
px = image.load()
counter: Counter = Counter()
for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
r, g, b, a = px[x, y]
if a > _ALPHA_FLOOR:
counter[(r, g, b)] += 1
if not counter:
return (0, 255, 0)
return counter.most_common(1)[0][0]
def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48):
"""An ``L`` mask, 255 where a pixel is within *tol* per-channel of *key*.
Tight on purpose: it only marks near-pure backdrop so trapped chroma pockets
seed the flood, while chroma-*tinted* character pixels stay outside it. Built
with channel point-ops (fast C), no per-pixel Python.
"""
from PIL import ImageChops
r, g, b, _a = image.split()
kr, kg, kb = key
return ImageChops.darker(
ImageChops.darker(
r.point(lambda v: 255 if abs(v - kr) <= tol else 0),
g.point(lambda v: 255 if abs(v - kg) <= tol else 0),
),
b.point(lambda v: 255 if abs(v - kb) <= tol else 0),
)
def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 90.0):
"""Return *image* (RGBA) with its flat background keyed out to transparent.
If the strip already has a transparent background we leave it alone; else we
key out *chroma_key* (or the dominant corner color when not given) via a
**border flood-fill**: only background-coloured pixels *connected to an edge*
are removed. A global color match (the old approach) punched holes in the pet
wherever an interior highlight happened to match the backdrop — e.g. a pug's
light belly against a near-white background — which then showed through as the
window behind. Flood-fill keeps those interior pixels because they aren't
reachable from the border without crossing the (non-background) pet.
"""
from collections import deque
from PIL import Image, ImageChops
rgba = image.convert("RGBA")
if _has_transparency(rgba):
return _repair_internal_alpha_holes(rgba)
key = chroma_key or _dominant_corner_color(rgba)
w, h = rgba.width, rgba.height
px = rgba.load()
def _is_bg(x: int, y: int) -> bool:
r, g, b, a = px[x, y]
return a > _ALPHA_FLOOR and _color_distance(r, g, b, key) <= threshold
# Fast path for strongly-saturated chroma keys (our normal sprite prompts use
# hot magenta): remove all near-key opaque pixels with C-level channel ops.
# This clears both border-connected backdrop and enclosed triangular pockets
# between connected limbs/capes, without a Python flood over ~1.5M pixels.
if max(key) - min(key) >= 120:
near = _near_key_mask(rgba, key) # L mask, 255 where near key
opaque = rgba.getchannel("A").point(lambda a: 255 if a > _ALPHA_FLOOR else 0)
remove_mask = ImageChops.darker(near, opaque)
return Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, remove_mask)
visited = bytearray(w * h)
# Mark removals in a flat mask and apply them in one C composite at the end —
# writing `px[x, y] = (0,0,0,0)` per pixel was ~3M PixelAccess calls (84% of
# the whole pipeline) and pegged a core in pure Python, stalling the gateway.
remove = bytearray(w * h)
queue: deque[tuple[int, int]] = deque()
# Seed from every border pixel that looks like background.
for x in range(w):
for y in (0, h - 1):
if _is_bg(x, y) and not visited[y * w + x]:
visited[y * w + x] = 1
queue.append((x, y))
for y in range(h):
for x in (0, w - 1):
if _is_bg(x, y) and not visited[y * w + x]:
visited[y * w + x] = 1
queue.append((x, y))
# Trapped pockets: background enclosed by the character (the magenta between
# an arm and the body) isn't border-reachable, so also seed the flood from
# interior near-key pixels. Gated to a *saturated* key (our magenta backdrop)
# so we never seed from a character sharing a desaturated near-white/gray key
# — that's the hole-punching the border-only flood exists to avoid.
if max(key) - min(key) >= 120:
for i, near in enumerate(_near_key_mask(rgba, key).getdata()):
if near and not visited[i]:
visited[i] = 1
queue.append((i % w, i // w))
while queue:
x, y = queue.popleft()
remove[y * w + x] = 1
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx]:
visited[idx] = 1
if _is_bg(nx, ny):
queue.append((nx, ny))
# One C-level composite instead of millions of per-pixel writes: paint the
# flooded pixels to (0,0,0,0) wherever the mask is set.
mask = Image.frombytes("L", (w, h), bytes(remove)).point(lambda v: 255 if v else 0)
return Image.composite(Image.new("RGBA", rgba.size, (0, 0, 0, 0)), rgba, mask)
def _repair_internal_alpha_holes(image):
"""Fill transparent islands fully enclosed by opaque sprite pixels.
Some providers return "transparent" PNGs with swiss-cheese alpha inside the
character. Border flood-fill cannot see those because there is no opaque
backdrop to key, so repair the alpha mask itself: transparent components that
touch an image edge remain background; transparent components enclosed by
the sprite are filled with the average color of their opaque neighbours.
"""
from collections import deque
rgba = image.convert("RGBA")
w, h = rgba.size
px = rgba.load()
visited = bytearray(w * h)
def _is_transparent(x: int, y: int) -> bool:
return px[x, y][3] <= _ALPHA_FLOOR
def _mark_border_component(sx: int, sy: int) -> None:
queue: deque[tuple[int, int]] = deque([(sx, sy)])
visited[sy * w + sx] = 1
while queue:
x, y = queue.popleft()
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx] and _is_transparent(nx, ny):
visited[idx] = 1
queue.append((nx, ny))
# First mark true background: all transparent pixels reachable from the edge.
for x in range(w):
for y in (0, h - 1):
if _is_transparent(x, y) and not visited[y * w + x]:
_mark_border_component(x, y)
for y in range(h):
for x in (0, w - 1):
if _is_transparent(x, y) and not visited[y * w + x]:
_mark_border_component(x, y)
def _collect_hole(sx: int, sy: int) -> list[tuple[int, int]]:
queue: deque[tuple[int, int]] = deque([(sx, sy)])
visited[sy * w + sx] = 1
pixels: list[tuple[int, int]] = []
while queue:
x, y = queue.popleft()
pixels.append((x, y))
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx] and _is_transparent(nx, ny):
visited[idx] = 1
queue.append((nx, ny))
return pixels
def _fill_color(hole: list[tuple[int, int]]) -> tuple[int, int, int, int]:
samples: list[tuple[int, int, int]] = []
seen = set(hole)
for x, y in hole:
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen:
r, g, b, a = px[nx, ny]
if a > _ALPHA_FLOOR:
samples.append((r, g, b))
if not samples:
return (0, 0, 0, 255)
return (
round(sum(c[0] for c in samples) / len(samples)),
round(sum(c[1] for c in samples) / len(samples)),
round(sum(c[2] for c in samples) / len(samples)),
255,
)
for start, _ in enumerate(visited):
if visited[start]:
continue
x = start % w
y = start // w
if not _is_transparent(x, y):
continue
hole = _collect_hole(x, y)
color = _fill_color(hole)
for hx, hy in hole:
px[hx, hy] = color
return rgba
# ───────────────────────── frame extraction ─────────────────────────
def _fit_to_cell(image):
"""Crop to content, scale to fit a padded cell, and center on transparent."""
from PIL import Image
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
image = _drop_side_bleed(image)
bbox = image.getbbox()
if bbox is None:
return target
sprite = image.crop(bbox)
max_w = CELL_WIDTH - _CELL_PAD
max_h = CELL_HEIGHT - _CELL_PAD
scale = min(max_w / sprite.width, max_h / sprite.height, 1.0)
if scale != 1.0:
# NEAREST, not LANCZOS: the generated "pixel art" has hard edges, and any
# interpolating resample anti-aliases them into a blurry, washed-out
# sprite once the renderer upscales the cell. Crisp blocky downscale reads
# as real pixel art.
sprite = sprite.resize(
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
Image.Resampling.NEAREST,
)
left = (CELL_WIDTH - sprite.width) // 2
top = (CELL_HEIGHT - sprite.height) // 2
target.alpha_composite(sprite, (left, top))
return target
def _drop_side_bleed(image):
"""Remove tiny separated left/right lobes before fitting a frame.
Frogger showed the failure mode: a good centered pose plus a thin vertical
sliver from the neighbouring pose. By the time it reaches a cell, that sliver
may be close enough to the subject that component extraction already grouped
it. A horizontal alpha projection still reveals it as a small side lobe with
a low mass compared to the main silhouette. Drop only those low-mass lobes;
keep large lobes so wide poses and real limbs survive.
"""
from PIL import Image
rgba = image.convert("RGBA")
w, h = rgba.size
profile = _column_profile(rgba) # mean alpha per column (fast C resize)
runs = _content_runs(profile)
if len(runs) < 2:
return rgba
masses = [sum(profile[l:r]) for l, r in runs]
keep_mass = max(masses) * _SIDE_LOBE_RATIO
keep = [run for run, m in zip(runs, masses) if m >= keep_mass]
if len(keep) == len(runs):
return rgba
# Zero every column band that isn't a kept segment (box paste, not per-pixel).
rgba = rgba.copy()
cut, prev = Image.new("RGBA", (w, h), (0, 0, 0, 0)), 0
for left, right in keep:
if left > prev:
rgba.paste(cut.crop((prev, 0, left, h)), (prev, 0))
prev = right
if prev < w:
rgba.paste(cut.crop((prev, 0, w, h)), (prev, 0))
return rgba
def _sever_expected_gutters(strip, frame_count: int):
"""Cut thin vertical gutters at expected frame boundaries before labeling.
Generated rows often have a shared shadow, glow, motion smear, or 1px bridge
that connects neighbouring poses. Component detection then sees one giant
blob and either fails or falls back to slot slicing. We know the requested
frame count, so cut a very narrow transparent band at each expected boundary
before connected-component labeling. If a pose truly overlaps the boundary,
losing a few pixels is better than exporting merged frames.
"""
if frame_count <= 1:
return strip
out = strip.copy()
px = out.load()
slot = out.width / frame_count
half = max(3, min(18, round(slot * 0.06)))
for i in range(1, frame_count):
x = round(i * slot)
left = max(0, x - half)
right = min(out.width, x + half + 1)
for gx in range(left, right):
for gy in range(out.height):
r, g, b, _a = px[gx, gy]
px[gx, gy] = (r, g, b, 0)
return out
def _slot_crops(strip, frame_count: int) -> list:
"""Slice *strip* into *frame_count* uniform columns (one coordinate space).
Equal-width columns keep every frame in a single shared coordinate frame, so
a later union-crop + shared placement (:func:`normalize_cells`) preserves the
row's real motion without the per-frame re-centering that makes a pet visibly
slide. Neighbour side-bleed is trimmed per column.
"""
w0 = max(1, strip.width // frame_count)
h = strip.height
return [_drop_side_bleed(strip.crop((i * w0, 0, i * w0 + w0, h))) for i in range(frame_count)]
def _content_runs(profile: list[int], *, threshold: int = 2) -> list[tuple[int, int]]:
"""Contiguous column spans whose alpha mass exceeds *threshold*.
A column-projection of the alpha mask: empty (background) columns separate
one pose from the next, so the runs ARE the candidate frames.
"""
runs: list[tuple[int, int]] = []
start: int | None = None
for x, v in enumerate(list(profile) + [0]):
if v > threshold:
if start is None:
start = x
elif start is not None:
runs.append((start, x))
start = None
return runs
def _frame_x_ranges(strip, frame_count: int) -> list[tuple[int, int]] | None:
"""Per-frame ``(left, right)`` column ranges from the row's empty gutters.
The standard sprite-sheet slice — once poses are separated by real gaps
(which generation now enforces), splitting is just "find the empty columns":
* spans == frames → one span per frame.
* spans > frames → merge across the smallest gaps. A detached halo/ear sits
a tiny gap from its body, while the inter-pose gutter is the big gap that
survives — so over-segmentation (and any over-eager gutter sever) repairs
itself by collapsing only the small internal gaps.
* spans < frames → poses are touching; not separable by gutters (the caller
raises for ``components`` or falls back to even slots for ``auto``).
Ranges span content only; the caller crops full cell height, so tall ears /
halos are never cut.
"""
profile = _column_profile(strip)
runs = _content_runs(profile)
if not runs:
return None
# Drop trivial specks so stray noise never counts as a pose.
masses = [sum(profile[l:r]) for l, r in runs]
floor = max(masses) * 0.02
runs = [run for run, m in zip(runs, masses) if m >= floor]
if len(runs) < frame_count:
return None
groups = [[l, r] for l, r in runs]
while len(groups) > frame_count:
gi = min(range(len(groups) - 1), key=lambda i: groups[i + 1][0] - groups[i][1])
groups[gi][1] = groups[gi + 1][1]
del groups[gi + 1]
return [(l, r) for l, r in groups]
def extract_strip_frames(
strip,
frame_count: int,
*,
chroma_key: tuple[int, int, int] | None = None,
method: str = "auto",
fit: bool = True,
) -> list:
"""Turn one generated row strip into *frame_count* frames.
The background is keyed out, thin connecting bridges at the expected
boundaries are severed, then the strip is sliced at its empty chroma gutters
(:func:`_frame_x_ranges`) — the plain "find each object, make a frame" cut
that works once poses are spaced apart (which generation now enforces).
Each frame is cropped at full cell height so tall ears / halos are never
clipped; :func:`_drop_side_bleed` trims any faint neighbour sliver. When the
poses are touching (fewer gutters than frames) ``components`` raises and
``auto`` falls back to equal-width slots.
*fit* (default) fits+centers each frame into a 192x208 cell — the standalone
contract for callers that don't normalize. Hatching passes ``fit=False`` to
keep raw, coordinate-aligned columns for :func:`normalize_cells`, which lays
one shared scale + baseline across the whole pet (no slide, no size pulse).
"""
from PIL import Image
if isinstance(strip, (str, Path)):
with Image.open(strip) as opened:
strip = opened.convert("RGBA")
else:
strip = strip.convert("RGBA")
strip = remove_background(strip, chroma_key=chroma_key)
# Prefer the real gutters as-is: when poses are already spaced (generation
# enforces this), slicing the strip untouched keeps each pose's own bounds and
# never cuts through an unevenly-placed silhouette. Only fall back to severing
# the expected boundaries when gaps alone can't separate the row — i.e. poses
# are bridged by a shared shadow/glow/1px line and read as one blob.
source = strip
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
source = _sever_expected_gutters(strip, frame_count)
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
if method == "components":
raise ValueError(f"could not segment {frame_count} sprites from strip")
frames = _slot_crops(source, frame_count)
else:
h = source.height
pad = max(2, min(16, round((source.width / max(1, frame_count)) * 0.04)))
frames = [
_drop_side_bleed(source.crop((max(0, left - pad), 0, min(source.width, right + pad), h)))
for left, right in ranges
]
return [_fit_to_cell(f) for f in frames] if fit else frames
def _column_profile(image) -> list[int]:
"""Per-column alpha mass — collapse the frame to a 1px-tall strip (fast in C)."""
from PIL import Image
return list(image.getchannel("A").resize((image.width, 1), Image.BILINEAR).getdata())
def _best_shift(ref: list[int], prof: list[int], window: int) -> int:
"""Integer dx that best aligns *prof* onto *ref* by cross-correlation.
This is 1-D phase correlation: the body is the dominant mass in the column
profile, so the peak overlap locks onto the body and a flipping arm/cape (a
small secondary bump) doesn't move the match. Proven on the jitter case to
cut body drift from ~9px to ~1px where a centroid/bbox anchor cannot.
"""
n = len(ref)
best_score: float | None = None
best = 0
for d in range(-window, window + 1):
score = 0
for x in range(max(0, d), min(n, n + d)):
score += ref[x] * prof[x - d]
if best_score is None or score > best_score:
best_score = score
best = d
return best
def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_PAD) -> dict[str, list]:
"""Register every frame into a 192x208 cell — the deterministic anti-jitter math.
A per-frame "crop→scale→center" pipeline jitters because a moving limb/cape
shifts the bbox (or even the centroid) and a per-frame scale pulses the size.
The rigorous fix, matching image-registration practice (phase correlation)
and AI-sprite pipelines (perfectpixel-studio / sprite-gen):
1. **Cross-correlate** each frame's column profile against the per-state
*median* profile to find the integer shift that locks the **body** in
place — robust to limbs/cape because the body dominates the profile.
2. **Union-crop** through one shared state window, then scale every state by a
single global factor keyed to its median pose height, so the character is
the same on-screen size in every row while a jump's lift still fits.
"""
from PIL import Image
blank = lambda: Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
med = lambda vs: sorted(vs)[len(vs) // 2] # robust center; ignores a limb/cape outlier
out: dict[str, list] = {}
prepared: dict[str, tuple[list, tuple[int, int, int, int], tuple[int, int]]] = {}
# Fill the cell — real petdex pets sit ~pad from the edges; the K cap below
# keeps a tall pose (a jump's lift) from clipping.
target_w = CELL_WIDTH - pad
target_h = CELL_HEIGHT - pad
for state, frames in frames_by_state.items():
rgba = [f.convert("RGBA") for f in frames]
if not any(f.getbbox() for f in rgba):
out[state] = [blank() for _ in frames]
continue
# Pad every frame to a common canvas so column profiles are comparable.
w0 = max(f.width for f in rgba)
h0 = max(f.height for f in rgba)
canvas = []
for f in rgba:
if f.size != (w0, h0):
c = Image.new("RGBA", (w0, h0), (0, 0, 0, 0))
c.alpha_composite(f, (0, 0))
f = c
canvas.append(f)
# Register horizontally: shift each frame to lock the body (xcorr).
profiles = [_column_profile(f) for f in canvas]
ref = [sorted(p[x] for p in profiles)[len(profiles) // 2] for x in range(w0)]
window = max(8, w0 // 5)
margin = window
aligned = []
for f, prof in zip(canvas, profiles):
shifted = Image.new("RGBA", (w0 + 2 * margin, h0), (0, 0, 0, 0))
shifted.alpha_composite(f, (margin + _best_shift(ref, prof, window), 0))
aligned.append(shifted)
# Shared window over the registered set; scale is resolved against a
# common apparent-character target below.
boxes = [b for b in (a.getbbox() for a in aligned) if b]
left = min(b[0] for b in boxes)
top = min(b[1] for b in boxes)
right = max(b[2] for b in boxes)
bottom = max(b[3] for b in boxes)
prepared[state] = (
aligned,
(left, top, right, bottom),
(med([b[2] - b[0] for b in boxes]), med([b[3] - b[1] for b in boxes])),
)
if not prepared:
return out
# Uniform apparent size: scale each state by K / pose_h, so a row the model
# drew small renders as big as one it drew large. K is the one global cap that
# keeps the tallest/widest motion envelope (a jump's lift) inside the cell —
# for a still row union ≈ pose so its term ≈ target_h (full fill).
K = target_h
for (_aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.values():
uw, uh = right - left, bottom - top
K = min(K, target_h * pose_h / max(1, uh), target_w * pose_h / max(1, uw))
for state, (aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.items():
uw, uh = right - left, bottom - top
scale = K / max(1, pose_h)
sw, sh = max(1, round(uw * scale)), max(1, round(uh * scale))
px, py = round((CELL_WIDTH - sw) / 2), round((CELL_HEIGHT - pad // 2) - sh)
cells = []
for a in aligned:
crop = a.crop((left, top, right, bottom))
if crop.size != (sw, sh):
# NEAREST keeps the pixel-art edges crisp; LANCZOS blurred them.
crop = crop.resize((sw, sh), Image.Resampling.NEAREST)
cell = blank()
cell.alpha_composite(crop, (px, py))
cells.append(cell)
out[state] = cells
return out
# ───────────────────────── atlas composition ─────────────────────────
def single_frame(image, *, fit: bool = True):
"""One frame from a standalone image (e.g. the base look).
Used as an idle fallback so a pet always renders even if the idle row
generation failed. *fit* yields a finished 192x208 cell; ``fit=False`` yields
the raw keyed sprite for :func:`normalize_cells` to place with the rest.
"""
from PIL import Image
if isinstance(image, (str, Path)):
with Image.open(image) as opened:
image = opened.convert("RGBA")
keyed = remove_background(image)
return _fit_to_cell(keyed) if fit else _drop_side_bleed(keyed)
def _clear_transparent_rgb(image):
"""Zero the RGB of fully-transparent pixels (no colored-halo residue)."""
from PIL import Image
rgba = image.convert("RGBA")
data = bytearray(rgba.tobytes())
for i in range(0, len(data), 4):
if data[i + 3] == 0:
data[i] = data[i + 1] = data[i + 2] = 0
return Image.frombytes("RGBA", rgba.size, bytes(data))
def mirror_frames(frames: list) -> list:
"""Horizontally flip each frame *in place* (RGBA-safe).
Used to derive ``running-left`` from an approved ``running-right`` row. The
flip is per-frame so the leftward loop preserves the rightward loop's frame
order and timing — this is NOT a whole-strip reverse (which would play the
animation backwards), matching the petdex/Codex mirror rule.
"""
from PIL import Image
flip = getattr(Image, "Transpose", Image).FLIP_LEFT_RIGHT
return [frame.convert("RGBA").transpose(flip) for frame in frames]
def compose_atlas(frames_by_state: dict[str, list]):
"""Pack per-state frame lists into the Hermes atlas (RGBA, residue-cleared).
Missing/short states leave their trailing cells transparent; extra frames
beyond a state's spec are dropped.
"""
from PIL import Image
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
for state, row, count in ROW_SPECS:
frames = frames_by_state.get(state) or []
for col, frame in enumerate(frames[:count]):
cell = frame.convert("RGBA")
if cell.size != (CELL_WIDTH, CELL_HEIGHT):
cell = _fit_to_cell(cell)
atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT))
return _clear_transparent_rgb(atlas)
def atlas_to_webp_bytes(atlas) -> bytes:
"""Encode an atlas image to lossless WebP bytes (the on-disk pet format)."""
buf = io.BytesIO()
atlas.save(buf, format="WEBP", lossless=True, quality=100, method=6, exact=True)
return buf.getvalue()
def validate_atlas(atlas) -> dict:
"""Check geometry, per-cell occupancy, and transparency invariants.
Returns ``{ok, width, height, errors, warnings, filled_states}``. Errors are
blockers (wrong size, empty used cell, opaque/dirty transparency); warnings
are soft (a whole state row blank — generation likely dropped a row).
"""
from PIL import Image
if isinstance(atlas, (str, Path)):
with Image.open(atlas) as opened:
atlas = opened.convert("RGBA")
else:
atlas = atlas.convert("RGBA")
errors: list[str] = []
warnings: list[str] = []
if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}")
return {"ok": False, "width": atlas.width, "height": atlas.height, "errors": errors, "warnings": warnings, "filled_states": []}
filled_states: list[str] = []
for state, row, count in ROW_SPECS:
row_pixels = 0
for col in range(count):
left = col * CELL_WIDTH
top = row * CELL_HEIGHT
cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
nonblank = sum(cell.getchannel("A").histogram()[1:])
row_pixels += nonblank
if row_pixels > 0:
filled_states.append(state)
else:
warnings.append(f"state '{state}' has no frames")
if not filled_states:
errors.append("atlas is empty — no state produced any frames")
# Transparent pixels must carry zero RGB (no halo residue).
data = atlas.tobytes()
residue = 0
for i in range(0, len(data), 4):
if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]):
residue += 1
if residue:
errors.append(f"{residue} transparent pixels retain RGB residue")
return {
"ok": not errors,
"width": atlas.width,
"height": atlas.height,
"errors": errors,
"warnings": warnings,
"filled_states": filled_states,
}

View File

@@ -1,233 +0,0 @@
"""Thin image-generation layer for pet sprites.
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
two things sprite generation needs that the agent-facing ``image_generate`` tool
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
animation row stays the same character as the chosen base).
Reference grounding only works on providers that support it — currently OpenAI
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
those and surface a clear, actionable error otherwise rather than silently
producing an ungrounded, drifting pet.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# Providers that can ground generation on a reference image.
# openrouter / nous reach Gemini Flash Image (and friends) over the
# OpenRouter-compatible chat-completions image protocol, which accepts
# reference images for grounding. Nous Portal proxies OpenRouter, so both
# qualify.
_REF_CAPABLE = ("openai", "openai-codex", "krea", "openrouter", "nous")
# Friendly label + one-line speed/quality note per reference-capable provider,
# surfaced in the desktop pet-gen picker so users can trade speed for fidelity.
_PROVIDER_META: dict[str, dict[str, str]] = {
"nous": {"label": "Nous Portal", "note": "Fast, balanced quality"},
"openrouter": {"label": "OpenRouter", "note": "Fastest — Gemini Flash Image"},
"openai": {"label": "OpenAI", "note": "Highest fidelity, slower"},
"openai-codex": {"label": "OpenAI (Codex)", "note": "Highest fidelity, slower"},
"krea": {"label": "Krea", "note": "Stylized, style-reference grounding"},
}
class GenerationError(RuntimeError):
"""Raised on any image-generation failure (no provider, API error, IO)."""
@dataclass(frozen=True)
class SpriteProvider:
"""Resolved provider plus whether it can take reference images."""
name: str
provider: object
supports_references: bool
def _discover() -> None:
try:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
except Exception as exc: # noqa: BLE001 - discovery is best-effort
logger.debug("image-gen plugin discovery failed: %s", exc)
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
"""Pick the image provider to use for sprite work.
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
reference-capable and configured, then the configured/active provider when
it's reference-capable, else the first available reference-capable provider.
With *require_references* off we fall back to any available provider (used for
prompt-only base drafts).
"""
_discover()
from agent.image_gen_registry import get_active_provider, get_provider
# An explicit user pick wins when it's reference-capable and has credentials;
# otherwise we ignore it and fall through to the normal resolution.
if prefer:
chosen = get_provider(prefer)
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
# Configured / active provider first.
active = None
try:
active = get_active_provider()
except Exception: # noqa: BLE001
active = None
if active is not None:
name = getattr(active, "name", "")
if name in _REF_CAPABLE and active.is_available():
return SpriteProvider(name=name, provider=active, supports_references=True)
# Any available reference-capable provider.
for name in _REF_CAPABLE:
provider = get_provider(name)
if provider is not None and provider.is_available():
return SpriteProvider(name=name, provider=provider, supports_references=True)
if not require_references and active is not None and active.is_available():
return SpriteProvider(
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
)
raise GenerationError(
"Pet generation needs an image backend that supports reference images. "
"Open `hermes tools` → Image Generation and configure Nous Portal, "
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
)
def list_sprite_providers() -> list[dict]:
"""The reference-capable providers available to pick for pet generation.
Returns ``[{name, label, note, default}]`` for every ref-capable provider the
user actually has credentials for, marking the one :func:`resolve_provider`
would choose with no explicit preference. Empty when none is configured (the
picker hides itself). Best-effort: discovery hiccups yield an empty list.
"""
_discover()
from agent.image_gen_registry import get_provider
try:
default_name = resolve_provider(require_references=True).name
except GenerationError:
default_name = ""
out: list[dict] = []
for name in _REF_CAPABLE:
provider = get_provider(name)
if provider is None or not provider.is_available():
continue
meta = _PROVIDER_META.get(name, {})
out.append(
{
"name": name,
"label": meta.get("label", name),
"note": meta.get("note", ""),
"default": name == default_name,
}
)
return out
def _save_local(image_ref: str, *, prefix: str) -> Path:
"""Return a local path for *image_ref*, downloading it if it's a URL."""
if image_ref.startswith(("http://", "https://")):
from agent.image_gen_provider import save_url_image
return Path(save_url_image(image_ref, prefix=prefix))
return Path(image_ref)
def _rejected_background(error: str) -> bool:
"""True when a provider error is specifically about the ``background`` param.
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
reject ``background=transparent`` outright). We detect that one rejection so
we can retry without the flag rather than failing the whole pet — our chroma
key pass makes the result transparent regardless.
"""
lowered = (error or "").lower()
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
def generate(
prompt: str,
*,
n: int = 1,
reference_images: list[Path] | None = None,
provider: SpriteProvider | None = None,
prefix: str = "pet_gen",
aspect_ratio: str = "square",
) -> list[Path]:
"""Generate *n* sprite images and return their local paths.
*reference_images* grounds the output on a base image (required for rows).
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
gives every frame real horizontal room so winged poses don't have to be
shrunk to avoid touching their neighbors).
We *ask* for a transparent background, but fall back to an opaque generation
(cleaned up downstream by the chroma-key pass) on models that reject the
flag. Raises :class:`GenerationError` if nothing usable comes back.
"""
sprite = provider or resolve_provider(require_references=bool(reference_images))
if reference_images and not sprite.supports_references:
raise GenerationError(
f"image backend '{sprite.name}' cannot use reference images; "
"configure OpenAI gpt-image-2 or Krea for pet generation"
)
refs = [str(p) for p in (reference_images or [])]
def _run(extra: dict) -> tuple[Path | None, str]:
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
if refs:
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
# ``reference_image_urls``. Send both; each ignores the other.
kwargs["reference_images"] = refs
kwargs["reference_image_urls"] = refs
try:
result = sprite.provider.generate(prompt, **kwargs)
except Exception as exc: # noqa: BLE001 - normalize provider crashes
logger.debug("provider.generate crashed: %s", exc)
return None, str(exc)
if not isinstance(result, dict) or not result.get("success"):
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
image_ref = result.get("image")
if not image_ref:
return None, "provider returned no image"
try:
return _save_local(str(image_ref), prefix=prefix), ""
except Exception as exc: # noqa: BLE001
return None, f"could not save generated image: {exc}"
out: list[Path] = []
last_error = ""
allow_transparent = True
for _ in range(max(1, n)):
path, err = _run({"background": "transparent"} if allow_transparent else {})
# Model doesn't support the transparent flag → drop it for this and every
# remaining variant (no point re-probing a capability we just disproved).
if path is None and allow_transparent and _rejected_background(err):
allow_transparent = False
path, err = _run({})
if path is not None:
out.append(path)
else:
last_error = err
if not out:
raise GenerationError(last_error or "image generation produced no output")
return out

View File

@@ -1,358 +0,0 @@
"""Pet generation orchestration — the base-draft → hatch flow.
Two steps, mirroring the UX across every surface:
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
look like" variants. Cheap; the user picks one (or retries for a fresh set).
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
strip per Hermes state, slices each into frames, composes the atlas, validates
it, and writes the pet into the store.
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
calls happen once, on the pet you actually keep) and gives each UI a natural
preview/loading point.
"""
from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from agent.pet.generate import atlas, imagegen, prompts
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
logger = logging.getLogger(__name__)
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
ProgressFn = Callable[[str, str], None]
# Image generations are independent network calls, so we fan them out instead of
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
# don't hammer the provider's rate limit (one cold call can still be slow).
_MAX_PARALLEL_GENERATIONS = 4
# How many times to (re)generate a single row before accepting a best-effort
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
# stubborn row still yields frames instead of dropping out entirely.
_ROW_GEN_ATTEMPTS = 2
_MIN_FILLED_STATES = 6
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
@dataclass(frozen=True)
class HatchResult:
"""Outcome of a successful :func:`hatch_pet`."""
slug: str
display_name: str
spritesheet: Path
states: list[str]
validation: dict
def _harden_transparency(path: Path) -> Path:
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
``background=transparent`` is requested on every call, but image models honor
it inconsistently — some still paint a flat (often near-white) backdrop. We
run the same chroma-key pass the row extractor uses so every base draft the
user picks between (and the reference the rows are grounded on) is a clean
cutout. Best-effort: a decode failure leaves the original untouched.
"""
from PIL import Image
try:
with Image.open(path) as opened:
keyed = atlas.remove_background(opened.convert("RGBA"))
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
# draft has no colored halo when composited on the dark UI.
keyed = atlas._clear_transparent_rgb(keyed)
out = path.with_suffix(".png")
keyed.save(out, format="PNG")
return out
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
return path
def generate_base_drafts(
concept: str,
*,
n: int = 4,
style: str = "auto",
reference_images: list[Path] | None = None,
provider: SpriteProvider | None = None,
on_draft: Callable[[int, Path], None] | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> list[Path]:
"""Generate *n* candidate base looks for *concept*; returns image paths.
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
Drafts are generated concurrently and *on_draft(index, path)* fires as each
one finishes (not at the end) so callers can stream previews to the UI
instead of leaving it blank until the whole batch is done.
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
started yet is skipped, and once it trips we stop staging/streaming further
drafts and cancel any queued work (already-in-flight provider calls can't be
hard-killed, but their results are dropped).
"""
# A user reference image (e.g. their own pet) grounds every draft, so it
# needs a reference-capable provider — same requirement as the row passes.
refs = reference_images or None
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
cancelled = is_cancelled or (lambda: False)
# Each draft is its own one-shot generation, run concurrently so the user
# waits for one image, not N. A single draft failing must not sink the set.
# Each gets a distinct variation nudge so the options aren't near-duplicates.
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
def _one(index: int) -> tuple[int, Path | None, str | None]:
if cancelled():
return index, None, None
t0 = time.monotonic()
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
try:
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
return index, None, str(exc)
if not out:
logger.warning("pet generate: draft %d produced no image", index)
return index, None, "the image provider returned no image"
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
return index, _harden_transparency(out[0]), None
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
results: dict[int, Path] = {}
errors: list[str] = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_one, i) for i in range(n)]
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
# gateway event it emits — inherits the request's bound transport, unlike
# the worker threads above.
for fut in as_completed(futures):
if cancelled():
logger.info("pet generate: cancelled — dropping remaining drafts")
for pending in futures:
pending.cancel()
break
index, path, err = fut.result()
if path is None:
if err:
errors.append(err)
continue
results[index] = path
if on_draft is not None:
try:
on_draft(index, path)
except Exception as exc: # noqa: BLE001 - progress is best-effort
logger.debug("on_draft callback failed: %s", exc)
drafts = [results[i] for i in sorted(results)]
if not drafts and not cancelled():
# Surface *why* — every draft failed for a reason (a content-policy refusal
# on a name like "minion", a provider/auth error, …); the most common one
# is the representative cause. Far more useful than "no usable drafts".
raise GenerationError(_drafts_failed_reason(errors))
return drafts
def _drafts_failed_reason(errors: list[str]) -> str:
"""The representative reason a draft round produced nothing, humanized."""
if not errors:
return "image generation produced no usable drafts"
from collections import Counter
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
def _humanize_image_error(error: str) -> str:
"""Turn a raw provider error into a friendly, actionable sentence.
The big one is moderation: image models refuse trademarked characters and
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
"""
low = error.lower()
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
return (
"The image provider blocked this prompt — its safety filter rejects "
"trademarked characters and real people. Try an original description."
)
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
return "The image provider rejected the request — check your API key in Settings → Providers."
if "rate limit" in low or "429" in low:
return "The image provider is rate-limiting — wait a moment and try again."
# Otherwise the first line, trimmed of the noisy provider envelope.
return error.splitlines()[0].strip()[:200]
def hatch_pet(
*,
base_image: str | Path,
slug: str,
display_name: str = "",
description: str = "",
concept: str = "",
style: str = "auto",
on_progress: ProgressFn | None = None,
provider: SpriteProvider | None = None,
is_cancelled: Callable[[], bool] | None = None,
) -> HatchResult:
"""Turn an approved base image into a full, installed Hermes pet.
Generates a grounded row strip per state, extracts frames, composes +
validates the atlas, and registers it. The idle row falls back to the base
look so the pet always renders. Raises :class:`GenerationError` on failure.
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
started are skipped, queued rows are cancelled, and once every row is done we
abort (raising :class:`GenerationError`) before composing/saving so a stopped
hatch never writes a half-built pet.
"""
base = Path(base_image)
if not base.is_file():
raise GenerationError(f"base image not found: {base}")
sprite = provider or imagegen.resolve_provider(require_references=True)
progress = on_progress or (lambda *_: None)
cancelled = is_cancelled or (lambda: False)
label = concept or display_name or slug
frames_by_state: dict[str, list] = {}
total_rows = len(atlas.ROW_SPECS)
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
# Generate every state's row strip concurrently — they're independent
# grounded calls, so the hatch waits for the slowest row, not their sum. A
# single row failing is tolerated (idle is guaranteed below).
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
state, _row, count = spec
if cancelled():
return state, None
t0 = time.monotonic()
last_exc: Exception | None = None
# Self-healing: a model occasionally returns a row whose poses are touching
# (no clean gutters), which slices badly. We retry such rolls; only the
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
# still yields *something* rather than dropping the whole row.
for attempt in range(_ROW_GEN_ATTEMPTS):
if cancelled():
return state, None
strict = attempt < _ROW_GEN_ATTEMPTS - 1
try:
strips = imagegen.generate(
prompts.build_row_prompt(state, count, label, style=style),
n=1,
reference_images=[base],
provider=sprite,
prefix=f"pet_row_{state}",
# Wider canvas → each frame gets real horizontal room, so winged
# poses keep a full, healthy size and still leave clean gutters.
aspect_ratio="landscape",
)
# ``components`` requires clean per-pose gutters (raises otherwise),
# so a touching roll is rejected and regenerated; the last attempt
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
# so normalize_cells registers the whole pet at once.
method = "components" if strict else "auto"
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
logger.info(
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
slug, state, time.monotonic() - t0, attempt + 1,
)
return state, frames
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
last_exc = exc
logger.warning(
"pet hatch %r: row %r attempt %d/%d failed: %s",
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
)
logger.warning(
"pet hatch %r: row %r gave up after %.1fs: %s",
slug, state, time.monotonic() - t0, last_exc,
)
return state, None
# running-left is derived by mirroring running-right (guaranteed-consistent
# and one fewer generation), so we don't generate it directly.
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
done = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
# as_completed runs on the caller (request) thread, so progress events
# emitted here inherit the request transport — unlike the worker threads.
for fut in as_completed(futures):
if cancelled():
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
for pending in futures:
pending.cancel()
break
state, frames = fut.result()
done += 1
progress("row", f"{state}:{done}:{total_rows}")
if frames:
frames_by_state[state] = frames
if cancelled():
raise GenerationError("hatch cancelled")
# Derive running-left from the approved running-right row (per-frame mirror,
# preserving order/timing). Missing running-right is rejected below; a pet
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
right = frames_by_state.get("running-right")
if right:
done += 1
progress("row", f"running-left:{done}:{total_rows}")
frames_by_state["running-left"] = atlas.mirror_frames(right)
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
else:
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
# Idle is the resting state the renderer falls back to — guarantee it.
if not frames_by_state.get("idle"):
progress("row", "idle-fallback")
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
progress("compose", "")
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
# One shared scale + baseline across every state so the pet never slides or
# pulses size between frames; compose just packs the normalized cells.
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
validation = atlas.validate_atlas(sheet)
if not validation["ok"]:
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
filled_states = set(validation["filled_states"])
missing_required = sorted(_REQUIRED_STATES - filled_states)
if missing_required:
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
if len(filled_states) < _MIN_FILLED_STATES:
raise GenerationError(
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
)
from agent.pet import store
progress("save", slug)
logger.info("pet hatch %r: saving pet", slug)
pet = store.register_local_pet(
sheet,
slug=slug,
display_name=display_name or slug,
description=description,
)
return HatchResult(
slug=pet.slug,
display_name=pet.display_name,
spritesheet=pet.spritesheet,
states=validation["filled_states"],
validation=validation,
)

View File

@@ -1,176 +0,0 @@
"""Prompt builders for pet generation.
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
produce one horizontal strip of N poses). Prompts stay concise and
sprite-production oriented; the identity lock and "one transparent row" framing
matter more than flowery description.
We generate the full petdex/Codex nine-state set (see
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
``petdex submit`` spritesheet.
"""
from __future__ import annotations
# What each petdex/Codex state should depict (kept short — these go straight into
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
# effects, motion lines, shadows). Critical distinction: ``running`` is the
# *working* state (in place), while ``running-right`` / ``running-left`` are the
# actual directional walk/run cycles.
STATE_ACTIONS: dict[str, str] = {
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
"running-right": (
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
"faces and travels right with clear directional steps, a smooth gait loop"
),
"running-left": (
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
"faces and travels left with clear directional steps (the mirror of the "
"right-facing run)"
),
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
"waiting": (
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
"or approval — distinct from idle and review"
),
"running": (
"focused active work, staying IN PLACE (NOT walking or foot-running): "
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
),
"review": "careful inspection: a focused lean, head tilt, studying something intently",
}
_STYLE_HINTS: dict[str, str] = {
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
"auto": (
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
"limited palette, clean dark outline, flat cel shading, chunky chibi "
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
),
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
"plush": " Render as a soft plush toy.",
"clay": " Render as a claymation / soft 3D clay figure.",
"sticker": " Render as a glossy die-cut sticker.",
"flat-vector": " Render in flat vector mascot style.",
"3d-toy": " Render as a glossy 3D toy.",
"painterly": " Render in a soft painterly style.",
}
_BACKGROUND = (
"Center one full-body character on a flat, uniform, high-contrast chroma-key "
"background (prefer pure hot magenta #FF00FF unless that color appears on "
"the character). The background must completely surround the character: one "
"even color with NO gradient, vignette, texture, pattern, scenery, shadow, "
"ground line, frame, or border, so it keys out cleanly. The background color "
"must not appear anywhere on the character itself. No text, no labels."
)
def style_hint(style: str | None) -> str:
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
# orchestrate). The extra width is what lets each pose stay a healthy size AND
# leave a real gutter — used here only to cite concrete pixel numbers.
_ASSUMED_STRIP_WIDTH = 1536
def _spacing_spec(frame_count: int) -> tuple[int, int]:
"""(per-pose width px, gap px) for a row of *frame_count* poses.
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
the full wingspan, so neighbors touch even when bodies are spaced. The lever
that works is proportional containment on a wide canvas: give each pose its
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
"""
slots = max(1, frame_count)
slot_w = _ASSUMED_STRIP_WIDTH / slots
pose_px = round(slot_w * 0.7)
gap_px = max(48, round(slot_w * 0.3))
return pose_px, gap_px
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
# near-duplicates for a single prompt. We vary the *look* (palette, build,
# expression, accents), NOT the pose, so the chosen base still grounds clean,
# consistent animation rows.
BASE_VARIATIONS: tuple[str, ...] = (
"",
"a distinctly different colour palette and markings",
"a heavier, broader silhouette with sturdier proportions",
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
"a leaner, taller build and an alternate colour scheme",
"bolder, more saturated colours and a stronger expression matching the concept tone",
)
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
"""The base look: a single, clean, centered full-body mascot.
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
"""
concept = (concept or "a distinctive mascot creature").strip()
nudge = f" Make this design distinct: {variation}." if variation else ""
return (
f"A stylized mascot pet character: {concept}. "
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
"while staying non-graphic. "
"Compact, whole-body silhouette that reads clearly at small size, "
"clear readable facial features, simple consistent palette. "
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
"relaxed at the sides, feet together on the ground, any cape/accessories "
"hanging straight and still."
f"{nudge} "
f"{_BACKGROUND}{style_hint(style)}"
)
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
"""A row strip: *frame_count* poses of the SAME character, left→right.
The attached base image is the identity source of truth; the prompt locks
species, palette, face, and props to it.
"""
action = STATE_ACTIONS.get(state, "a simple idle pose")
concept = (concept or "the mascot").strip()
pose_px, gap_px = _spacing_spec(frame_count)
return (
f"Using the attached reference image as the exact same character "
f"(same species, face, colors, markings, proportions, and props), "
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
f"LAYOUT: split the wide strip into {frame_count} equal vertical cells, one "
"pose centered in each cell. "
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
f"the same empty margin before the first pose and after the last. If a wing, "
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
f"share a shadow, share a ground line, share motion trails, or merge into "
f"one connected shape. "
# Registration: a clean sprite sheet keeps the character locked in place
# so only the action moves — this is what stops the loop sliding/pulsing.
"REGISTRATION (critical): the character is the SAME height and SAME width "
"in every frame, drawn at the SAME scale, centered over the SAME point, "
"with all feet resting on ONE shared horizontal ground line across the "
"whole strip. Keep the body's center, size, and stance fixed frame to "
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
"bags, and scarves stay in the SAME place and shape every frame (no "
"swinging, flowing, or drifting) unless the action itself requires it. No "
"pose is cropped at the strip edges. "
f"{_BACKGROUND}{style_hint(style)}"
)

View File

@@ -1,165 +0,0 @@
"""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 threading
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
MANIFEST_URL = "https://petdex.dev/api/manifest"
_DEFAULT_TIMEOUT = 10.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
_prefetch_lock = threading.Lock()
_prefetching = False
def clear_cache() -> None:
"""Drop the cached manifest (forces the next fetch to hit the network)."""
global _cache
_cache = None
def _cache_is_warm() -> bool:
return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
The desktop picker calls this when it loads the (instant) local-only gallery
so the full petdex catalog is usually cached by the time it's requested,
without ever holding up the user's own pets on a network round-trip.
"""
global _prefetching
if _cache_is_warm():
return
with _prefetch_lock:
if _prefetching:
return
_prefetching = True
def _run() -> None:
global _prefetching
try:
fetch_manifest(timeout=timeout)
except Exception as exc: # noqa: BLE001 - best-effort warm
logger.debug("petdex manifest prefetch failed: %s", exc)
finally:
_prefetching = False
threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start()
@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

View File

@@ -1,618 +0,0 @@
"""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,
)

View File

@@ -1,81 +0,0 @@
"""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

View File

@@ -1,503 +0,0 @@
"""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
import re
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
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
@property
def exists(self) -> bool:
return self.spritesheet.is_file()
@property
def generated(self) -> bool:
return self.created_by == "generator"
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 _safe_slug(slug: str) -> str:
"""Normalize a slug to a single bare path segment.
Pet slugs index into ``pets_dir()/<slug>/`` for load/remove, so a value
carrying path separators (``../``, absolute paths) could escape the pets
directory. Strip every separator and reject ``.``/``..`` so callers can
only ever name a direct child of the pets directory.
"""
segment = Path(str(slug).strip()).name
if segment in ("", ".", ".."):
return ""
return segment
def load_pet(slug: str) -> InstalledPet | None:
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
slug = _safe_slug(slug)
if not slug:
return None
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),
created_by=str(meta.get("createdBy", "") or ""),
)
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 = _safe_slug(slug)
if not slug:
raise PetStoreError("invalid pet slug")
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")
# Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from
# petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest
# can't redirect the download at an arbitrary host. Matches thumbnail_png.
if not _is_petdex_host(entry.spritesheet_url):
raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'")
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 and _is_petdex_host(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
def slugify(name: str) -> str:
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
return slug or "pet"
def unique_slug(name: str) -> str:
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
base = slugify(name)
slug = base
counter = 2
while (pets_dir() / slug).exists():
slug = f"{base}-{counter}"
counter += 1
return slug
def _write_spritesheet(source, dest: Path) -> None:
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
if isinstance(source, (bytes, bytearray)):
dest.write_bytes(bytes(source))
return
from PIL import Image
if isinstance(source, (str, Path)):
with Image.open(source) as opened:
image = opened.convert("RGBA")
else:
image = source.convert("RGBA")
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
def register_local_pet(
spritesheet,
*,
slug: str,
display_name: str = "",
description: str = "",
) -> InstalledPet:
"""Write a locally-generated pet into the store and return it.
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
returns an already-on-disk pet before consulting the manifest, it can be
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
"""
slug = slugify(slug)
directory = pets_dir() / slug
directory.mkdir(parents=True, exist_ok=True)
sprite_path = directory / "spritesheet.webp"
try:
_write_spritesheet(spritesheet, sprite_path)
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
meta = {
"id": slug,
"displayName": display_name or slug,
"description": description or "",
"spritesheetPath": sprite_path.name,
"createdBy": "generator",
}
(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"register of generated pet '{slug}' did not produce a spritesheet")
return pet
def export_pet(slug: str) -> tuple[str, bytes]:
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
re-importable pet package. Raises :class:`PetStoreError` if not installed.
"""
import io
import zipfile
root = pets_dir()
directory = root / slug.strip()
# Guard against traversal: the target must be a direct child of pets_dir.
if directory.resolve().parent != root.resolve() or not directory.is_dir():
raise PetStoreError(f"pet '{slug}' is not installed")
name = directory.name
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(directory.iterdir()):
if path.is_file() and not path.name.startswith("."):
archive.write(path, f"{name}/{path.name}")
return f"{name}.zip", buf.getvalue()
_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
slug = _safe_slug(slug)
if not slug:
return False
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
# slug renders this one's stale thumbnail.
try:
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
except OSError:
pass
directory = pets_dir() / slug
if not directory.is_dir():
return False
shutil.rmtree(directory, ignore_errors=True)
return not directory.exists()
def rename_pet(slug: str, display_name: str) -> str | None:
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
Generated pets are hatched under a provisional, prompt-derived slug; when
the user names the pet on the reveal screen we make that name the real
identity so lists/subtitles show what they typed, not the prompt. The dir is
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
whenever that yields a free, different slug — otherwise the slug is left as
is. Returns the resulting slug on success, or ``None`` on failure.
"""
slug = _safe_slug(slug)
display_name = (display_name or "").strip()
if not slug or not display_name:
return None
directory = pets_dir() / slug
pet_json = directory / "pet.json"
if not pet_json.is_file():
return None
try:
meta = json.loads(pet_json.read_text(encoding="utf-8"))
except (OSError, ValueError):
meta = {}
if not isinstance(meta, dict):
meta = {}
meta["displayName"] = display_name
new_slug = slug
desired = slugify(display_name)
if desired and desired != slug and not (pets_dir() / desired).exists():
try:
directory.rename(pets_dir() / desired)
try:
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
except OSError:
pass
directory = pets_dir() / desired
pet_json = directory / "pet.json"
new_slug = desired
meta["id"] = new_slug
except OSError:
new_slug = slug # keep the provisional slug if the move fails
try:
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
except OSError:
return None
return new_slug
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 {}

View File

@@ -709,24 +709,7 @@ PLATFORM_HINTS = {
"(those are only intercepted on messaging platforms like Telegram, "
"Discord, Slack, etc.; on the CLI they render as literal text). "
"When referring to a file you created or changed, just state its "
"absolute path in plain text; the user can open it from there. "
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
"saved (viewable via cronjob action='list') but is NOT delivered back "
"into this terminal — there is no live-delivery channel here. If the "
"user wants to be notified when a job runs, the job's `deliver` must "
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"tui": (
"You are running in the Hermes terminal UI (TUI). "
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
"saved (viewable via cronjob action='list') but is NOT delivered back "
"into this TUI session — there is no live-delivery channel here. If the "
"user wants to be notified when a job runs, the job's `deliver` must "
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
"absolute path in plain text; the user can open it from there."
),
"sms": (
"You are communicating via SMS. Keep responses concise and use plain text "

View File

@@ -69,35 +69,12 @@ def _budget_for_agent(agent) -> BudgetConfig:
_MAX_TOOL_WORKERS = 8
def _flush_session_db_after_tool_progress(
agent,
messages: list,
*,
stage: str,
) -> None:
"""Best-effort incremental SessionDB flush for tool-call progress.
Tool execution can perform side effects that terminate or restart the
current Hermes process before the normal turn-end persistence path runs.
Flush the already-appended assistant/tool messages immediately so the
transcript survives destructive-but-valid tool calls.
"""
try:
agent._flush_messages_to_session_db(messages)
except Exception as exc:
logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc)
def _ra():
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
import run_agent
return run_agent
def _is_interpreter_shutdown_submit_error(exc: RuntimeError) -> bool:
return "cannot schedule new futures after interpreter shutdown" in str(exc)
def _emit_terminal_post_tool_call(
agent,
*,
@@ -302,11 +279,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"cancelled tool result {tc.function.name}",
)
return
# ── Parse args + pre-execution bookkeeping ───────────────────────
@@ -609,40 +581,13 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if runnable_calls:
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for submit_index, (i, tc, name, args) in enumerate(runnable_calls):
for i, tc, name, args in runnable_calls:
# Propagate the agent turn's ContextVars (e.g.
# _approval_session_key) AND thread-local approval/sudo
# callbacks into the worker thread; clears callbacks on exit.
try:
f = executor.submit(
propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3]
)
except RuntimeError as submit_error:
if not _is_interpreter_shutdown_submit_error(submit_error):
raise
skipped_calls = runnable_calls[submit_index:]
logger.warning(
"interpreter shutdown while scheduling concurrent tools; "
"skipping %d unsubmitted tool(s)",
len(skipped_calls),
)
for skipped_i, _tc, skipped_name, skipped_args in skipped_calls:
if results[skipped_i] is None:
middleware_trace = parsed_calls[skipped_i][3]
result = (
f"Error executing tool '{skipped_name}': "
"Python interpreter is shutting down; tool was not started"
)
results[skipped_i] = (
skipped_name,
skipped_args,
result,
0.0,
True,
False,
middleware_trace,
)
break
f = executor.submit(
propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3]
)
futures.append(f)
# Wait for all to complete with periodic heartbeats so the
@@ -823,11 +768,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# String results pass through unchanged.
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
messages.append(make_tool_result_message(name, _tool_content, tc.id))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Same as the sequential path: drain between each collected
@@ -863,16 +803,13 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True)
for skipped_tc in remaining_calls:
skipped_name = skipped_tc.function.name
messages.append(make_tool_result_message(
skipped_name,
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
skipped_tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"cancelled tool result {skipped_name}",
)
skip_msg = {
"role": "tool",
"name": skipped_name,
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
"tool_call_id": skipped_tc.id,
}
messages.append(skip_msg)
break
function_name = tool_call.function.name
@@ -1465,11 +1402,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# (see parallel path for rationale). String results pass through.
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {function_name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Drain pending steer BETWEEN individual tool calls so the
@@ -1496,11 +1428,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
skipped_tc.id,
))
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"skipped tool result {skipped_name}",
)
break
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):

View File

@@ -29,10 +29,7 @@ from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.iteration_budget import IterationBudget
from agent.model_metadata import (
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
)
from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
@@ -60,34 +57,6 @@ def _compression_made_progress(
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
def _should_run_preflight_estimate(
messages: List[Dict[str, Any]],
protect_first_n: int,
protect_last_n: int,
threshold_tokens: int,
) -> bool:
"""Cheap gate for the (expensive) full preflight token estimate.
Returns ``True`` when either:
(a) message count exceeds the protected ranges (the historical gate), or
(b) a cheap char-based estimate already crosses the configured threshold
— the few-but-huge case from issue #27405 that the count-only gate
would silently skip (a handful of very large messages never trips
the count condition, so compression was never attempted and the
turn hit a hard context-overflow error).
Branch (b) uses ``estimate_messages_tokens_rough`` (the shared char-based
estimator) so a single large base64 image isn't mistaken for ~250K tokens.
It intentionally undercounts vs. the full request estimate — it omits the
system prompt and tool schemas — because it is only a *hint* deciding
whether to pay for the authoritative ``estimate_request_tokens_rough``,
which (together with ``should_compress``) makes the real decision.
"""
if len(messages) > protect_first_n + protect_last_n + 1:
return True
return estimate_messages_tokens_rough(messages) >= threshold_tokens
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
@@ -320,14 +289,10 @@ def build_turn_context(
)
# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
# issue #27405 (a few very large messages slipping past the count gate).
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
agent.context_compressor.protect_last_n,
agent.context_compressor.threshold_tokens,
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
_preflight_tokens = estimate_request_tokens_rough(
messages,

View File

@@ -166,29 +166,6 @@ def finalize_turn(
# same empty-response loop again.
try:
agent._drop_trailing_empty_response_scaffolding(messages)
# When the turn was interrupted and the last message is a tool
# result, append a synthetic assistant message to close the
# tool-call sequence. Without this, the session persists a
# ``tool → user`` alternation that strict providers (Gemini,
# Claude) reject, causing them to hallucinate a continuation of
# the user's message on the next turn (#48879).
#
# ``_drop_trailing_empty_response_scaffolding`` only rewinds the
# tool tail when an empty-response scaffolding flag is present; a
# clean ``/stop`` interrupt after a successful tool sets no such
# flag, so the tool result survives as the tail and we close it
# here instead. On an interrupt ``final_response`` is typically
# empty, so fall back to an explicit placeholder rather than
# persisting an empty-content assistant turn.
if interrupted and messages and messages[-1].get("role") == "tool":
messages.append(
{
"role": "assistant",
"content": (final_response or "").strip() or "Operation interrupted.",
}
)
agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")

View File

@@ -12,7 +12,6 @@ const {
powerMonitor,
protocol,
safeStorage,
screen,
session,
shell,
systemPreferences
@@ -57,7 +56,6 @@ const {
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
const {
buildPosixCleanupScript,
@@ -69,13 +67,6 @@ const {
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
const {
MIN_WIDTH: WINDOW_MIN_WIDTH,
MIN_HEIGHT: WINDOW_MIN_HEIGHT,
sanitizeWindowState,
computeWindowOptions,
debounce
} = require('./window-state.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@@ -329,7 +320,6 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json')
// active-profile.json records which Hermes profile the desktop launches its
// local backend as. When set, startHermes() passes `hermes --profile <name>
// dashboard …`, which deterministically pins HERMES_HOME (see
@@ -1532,36 +1522,6 @@ function writeDesktopUpdateConfig(config) {
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
}
// ─── Main-window geometry persistence (window-state.json) ──────────────────
function readWindowState() {
try {
return sanitizeWindowState(JSON.parse(fs.readFileSync(DESKTOP_WINDOW_STATE_PATH, 'utf8')))
} catch {
return null
}
}
// Persist the window's restored (non-maximized) bounds plus its maximized flag.
// getNormalBounds() keeps the pre-maximize size, so un-maximizing next session
// lands back where the user actually sized the window.
function persistWindowState() {
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return
try {
const { x, y, width, height } = mainWindow.getNormalBounds()
fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true })
writeFileAtomic(
DESKTOP_WINDOW_STATE_PATH,
JSON.stringify({ x, y, width, height, isMaximized: mainWindow.isMaximized() }, null, 2)
)
} catch (err) {
rememberLog(`[window-state] persist failed: ${err?.message || err}`)
}
}
// resized/moved fire many times mid-drag on Linux; debounce to one write.
const schedulePersistWindowState = debounce(persistWindowState, 250)
// Match the backend's source resolution but bias toward a real git checkout.
// Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
// HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
@@ -1707,34 +1667,15 @@ async function checkUpdates() {
}
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([
const [currentSha, targetSha, countStr, dirtyStr, currentBranch] = await Promise.all([
git(['rev-parse', 'HEAD']),
git(['rev-parse', `origin/${branch}`]),
git(['rev-list', `HEAD..origin/${branch}`, '--count']),
git(['status', '--porcelain']),
git(['rev-parse', '--abbrev-ref', 'HEAD']),
git(['rev-parse', '--is-shallow-repository']),
// merge-base exits non-zero with empty stdout when HEAD shares no common
// ancestor with the freshly fetched tip — exactly the shallow-clone case.
git(['merge-base', 'HEAD', `origin/${branch}`])
git(['rev-parse', '--abbrev-ref', 'HEAD'])
])
const isShallow = shallowStr === 'true'
const hasMergeBase = Boolean(mergeBaseStr)
// Only enumerate the commit count when it is meaningful. On a shallow checkout
// with no merge-base, `rev-list --count` walks the entire remote ancestry
// (thousands of commits, see #51922) and resolveBehindCount discards the
// result anyway in favour of a SHA compare — so skip the expensive query.
const countStr = shouldCountCommits({ isShallow, hasMergeBase })
? await git(['rev-list', `HEAD..origin/${branch}`, '--count'])
: ''
const behind = resolveBehindCount({
countStr,
currentSha,
targetSha,
isShallow,
hasMergeBase
})
const behind = Number.parseInt(countStr, 10) || 0
const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []
return {
@@ -5444,149 +5385,13 @@ 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()
const savedWindowState = readWindowState()
mainWindow = new BrowserWindow({
...computeWindowOptions(savedWindowState, screen.getAllDisplays()),
minWidth: WINDOW_MIN_WIDTH,
minHeight: WINDOW_MIN_HEIGHT,
width: 1220,
height: 800,
minWidth: 400,
minHeight: 620,
title: 'Hermes',
// Frameless title bar on every platform so the renderer can paint the
// "hide sidebar" button (and other left-side titlebar tools) flush with
@@ -5628,8 +5433,6 @@ function createWindow() {
}
}
if (savedWindowState?.isMaximized) mainWindow.maximize()
mainWindow.once('ready-to-show', () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
})
@@ -5639,19 +5442,6 @@ function createWindow() {
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
// Reopen where the user left off. resized/moved settle once per drag; close is
// the cross-platform backstop, flushed synchronously before the window is gone.
mainWindow.on('resized', schedulePersistWindowState)
mainWindow.on('moved', schedulePersistWindowState)
mainWindow.on('maximize', schedulePersistWindowState)
mainWindow.on('unmaximize', schedulePersistWindowState)
mainWindow.on('close', () => schedulePersistWindowState.flush())
// 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) => {
@@ -5772,116 +5562,6 @@ 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
@@ -7092,10 +6772,6 @@ 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 {

View File

@@ -7,32 +7,6 @@ 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),

View File

@@ -1,28 +0,0 @@
'use strict'
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
// number worth computing. On a SHALLOW checkout (installer clones with
// --depth 1) the local history often shares no merge-base with the freshly
// fetched origin tip, so the count enumerates the entire remote ancestry and
// returns a bogus huge number (e.g. 12104) — see #51922. resolveBehindCount
// discards that bogus count in favour of a SHA compare, so the caller should
// SKIP the expensive rev-list entirely in that case rather than run it and
// throw the result away.
function shouldCountCommits({ isShallow, hasMergeBase }) {
return !(isShallow && !hasMergeBase)
}
// Resolve how many commits the local checkout is behind origin for the desktop
// update indicator. When the count isn't meaningful (shallow + no merge-base)
// fall back to a binary up-to-date check by SHA, exactly like the official-SSH
// path in checkUpdates() and the CLI guard in hermes_cli/banner.py. Full clones
// (developers / Docker dev images) keep the exact count path unchanged.
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
if (!shouldCountCommits({ isShallow, hasMergeBase })) {
if (currentSha && targetSha && currentSha === targetSha) return 0
return 1 // behind by an unknown amount — show a generic "update available"
}
return Number.parseInt(countStr, 10) || 0
}
module.exports = { resolveBehindCount, shouldCountCommits }

View File

@@ -1,79 +0,0 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
isShallow: true, hasMergeBase: false,
}), 0)
})
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
assert.equal(resolveBehindCount({
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: true,
}), 3)
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
assert.equal(resolveBehindCount({
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 7)
})
test('up-to-date full clone reports 0', () => {
assert.equal(resolveBehindCount({
countStr: '0', currentSha: 'x', targetSha: 'x',
isShallow: false, hasMergeBase: true,
}), 0)
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 0)
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
// FAIL-BEFORE: in the shallow + no-merge-base case the caller ran rev-list
// unconditionally and discarded the bogus result; this predicate lets the
// caller SKIP the whole-ancestry enumeration in exactly that case (#51922).
test('shallow checkout with no merge-base SKIPS the rev-list count', () => {
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: false }), false)
})
test('shallow checkout WITH a merge-base still runs the count', () => {
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: true }), true)
})
test('full (non-shallow) clone always runs the count', () => {
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: true }), true)
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: false }), true)
})
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'same', targetSha: 'same',
isShallow: true, hasMergeBase: false,
}), 0)
})

View File

@@ -1,117 +0,0 @@
/**
* Pure geometry helpers for window-state.json — restoring the main window's
* size, position, and maximized flag across launches. Side-effect-free so the
* part that actually matters (rejecting garbage + off-screen bounds) is
* unit-testable without booting Electron; main.cjs owns the file I/O and the
* live `screen` displays.
*/
// Defaults mirror the historical hardcoded BrowserWindow size; MIN_* mirror its
// minWidth/minHeight so a restored size never undershoots what the live window
// allows. A fresh install (no saved state) is byte-identical to before.
const DEFAULT_WIDTH = 1220
const DEFAULT_HEIGHT = 800
const MIN_WIDTH = 400
const MIN_HEIGHT = 620
// Keep at least this much of the window over a display work area before we trust
// a saved position, so the title bar stays grabbable after a monitor unplugs.
const MIN_VISIBLE = 48
const finite = v => typeof v === 'number' && Number.isFinite(v)
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi))
// Parse raw JSON → clean state, or null if garbage. width/height are required
// and floored; x/y survive only as a finite pair; isMaximized is strict.
function sanitizeWindowState(raw) {
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
const state = {
width: Math.max(MIN_WIDTH, Math.round(raw.width)),
height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
isMaximized: raw.isMaximized === true
}
if (finite(raw.x) && finite(raw.y)) {
state.x = Math.round(raw.x)
state.y = Math.round(raw.y)
}
return state
}
// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
// axes. `displays` is Electron's screen.getAllDisplays() shape.
function onScreen(bounds, displays) {
if (!Array.isArray(displays)) return false
return displays.some(({ workArea: a } = {}) => {
if (!a) return false
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
return x >= MIN_VISIBLE && y >= MIN_VISIBLE
})
}
// Sanitized state (or null) → BrowserWindow size/position options. Always sets
// width/height, capped to the largest current display so a size saved on a
// since-disconnected bigger monitor can't exceed any screen the user now has.
// Sets x/y only when still on-screen; otherwise Electron centers the window.
function computeWindowOptions(state, displays) {
const opts = {
width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
}
const cap = (Array.isArray(displays) ? displays : []).reduce(
(m, { workArea: a } = {}) =>
a && finite(a.width) && finite(a.height)
? { width: Math.max(m.width, a.width), height: Math.max(m.height, a.height) }
: m,
{ width: 0, height: 0 }
)
if (cap.width && cap.height) {
opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
}
if (
state &&
finite(state.x) &&
finite(state.y) &&
onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
) {
opts.x = state.x
opts.y = state.y
}
return opts
}
// Trailing debounce: collapse a burst of resize/move events (Linux fires many
// mid-drag) into a single run `delayMs` after the last. `.flush()` runs now and
// cancels the pending timer — used on close, before the window is gone.
function debounce(fn, delayMs) {
let timer = null
const debounced = () => {
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
fn()
}, delayMs)
}
debounced.flush = () => {
clearTimeout(timer)
timer = null
fn()
}
return debounced
}
module.exports = {
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
MIN_WIDTH,
MIN_HEIGHT,
MIN_VISIBLE,
sanitizeWindowState,
onScreen,
computeWindowOptions,
debounce
}

View File

@@ -1,135 +0,0 @@
/**
* Unit tests for the pure window-state geometry helpers. These cover the logic
* that protects the user: garbage rejection, off-screen fallback, oversized
* clamping, and the debounce that collapses mid-drag write storms.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
MIN_WIDTH,
MIN_HEIGHT,
sanitizeWindowState,
onScreen,
computeWindowOptions,
debounce
} = require('./window-state.cjs')
// A single 1920×1080 monitor (work area trimmed for the taskbar).
const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }]
// A laptop panel left behind after a bigger external monitor is unplugged.
const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
// ─── sanitizeWindowState ───────────────────────────────────────────────────
test('sanitizeWindowState rejects missing/garbage input', () => {
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
assert.equal(sanitizeWindowState(bad), null)
}
})
test('sanitizeWindowState keeps a valid full state and rounds HiDPI fractions', () => {
assert.deepEqual(sanitizeWindowState({ x: 100.6, y: 50.2, width: 1400.4, height: 900.7, isMaximized: true }), {
x: 101,
y: 50,
width: 1400,
height: 901,
isMaximized: true
})
})
test('sanitizeWindowState floors size to the minimums', () => {
const state = sanitizeWindowState({ width: 10, height: 10 })
assert.equal(state.width, MIN_WIDTH)
assert.equal(state.height, MIN_HEIGHT)
})
test('sanitizeWindowState drops a partial position but keeps the size', () => {
assert.deepEqual(sanitizeWindowState({ x: 100, width: 1400, height: 900 }), {
width: 1400,
height: 900,
isMaximized: false
})
})
test('sanitizeWindowState treats isMaximized strictly', () => {
assert.equal(sanitizeWindowState({ width: 1400, height: 900, isMaximized: 'yes' }).isMaximized, false)
})
// ─── onScreen ──────────────────────────────────────────────────────────────
test('onScreen accepts a window on the primary or a secondary display', () => {
const dual = [...PRIMARY, { workArea: { x: 1920, y: 0, width: 2560, height: 1400 } }]
assert.equal(onScreen({ x: 100, y: 100, width: 1220, height: 800 }, PRIMARY), true)
assert.equal(onScreen({ x: 2200, y: 200, width: 1220, height: 800 }, dual), true)
})
test('onScreen rejects off-screen, slivers, and bad input', () => {
assert.equal(onScreen({ x: 3000, y: 100, width: 1220, height: 800 }, PRIMARY), false) // past right edge
assert.equal(onScreen({ x: 100, y: -900, width: 1220, height: 800 }, PRIMARY), false) // above top
assert.equal(onScreen({ x: 1910, y: 100, width: 1220, height: 800 }, PRIMARY), false) // ~10px sliver
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, []), false)
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, null), false)
})
// ─── computeWindowOptions ──────────────────────────────────────────────────
test('computeWindowOptions falls back to defaults with no saved state', () => {
assert.deepEqual(computeWindowOptions(null, PRIMARY), { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT })
})
test('computeWindowOptions restores an on-screen position', () => {
const saved = sanitizeWindowState({ x: 200, y: 150, width: 1400, height: 900 })
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900, x: 200, y: 150 })
})
test('computeWindowOptions keeps the size but drops an off-screen position', () => {
const saved = sanitizeWindowState({ x: 5000, y: 150, width: 1400, height: 900 })
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900 })
})
test('computeWindowOptions clamps a size larger than the only display', () => {
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
assert.deepEqual(computeWindowOptions(saved, LAPTOP), { width: 1366, height: 728 })
})
test('computeWindowOptions keeps the MIN floor on a sub-minimum display', () => {
const tiny = [{ workArea: { x: 0, y: 0, width: 360, height: 480 } }]
const saved = sanitizeWindowState({ width: 2000, height: 1500 })
assert.deepEqual(computeWindowOptions(saved, tiny), { width: MIN_WIDTH, height: MIN_HEIGHT })
})
test('computeWindowOptions does not clamp when displays are unknown', () => {
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 })
})
// ─── debounce ──────────────────────────────────────────────────────────────
test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
d(); d(); d()
assert.equal(calls, 0)
t.mock.timers.tick(249)
assert.equal(calls, 0)
t.mock.timers.tick(1)
assert.equal(calls, 1)
})
test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
d()
d.flush()
assert.equal(calls, 1)
t.mock.timers.tick(1000)
assert.equal(calls, 1)
})

View File

@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View File

@@ -9,9 +9,9 @@ import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $activeSessionId } from '@/store/session'
import {
$subagentsBySession,
allSubagents,
buildSubagentTree,
type SubagentNode,
type SubagentStatus,
@@ -77,12 +77,15 @@ interface AgentsViewProps {
export function AgentsView({ onClose }: AgentsViewProps) {
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const subagentsBySession = useStore($subagentsBySession)
// Aggregate every session, matching the status-bar indicator — a subagent
// running in a background session must still be visible here, or the two
// desync ("Agents N running" vs an empty tree).
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
const activeSubagents = useMemo(
() => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []),
[activeSessionId, subagentsBySession]
)
const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents])
return (
<OverlayView

View File

@@ -1,106 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, render } from '@testing-library/react'
import { useCallback, useRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(cleanup)
// Regression repro for #49903: on desktop v0.17.0 the composer threw an
// uncaught `Error: Composer is not available` at startup and the input went
// unresponsive. The throw comes from @assistant-ui/core's composer-runtime —
// every *mutator* (setText/send/…) does `if (!core) throw new Error("Composer
// is not available")` when the thread's composer core isn't bound yet. Unlike
// the read path (`s.composer.text`, which is null-safe: `runtime?.text ?? ""`),
// the mutators have no graceful fallback. ChatBar's mount-time effects (draft
// restore, clearDraft, external inserts) push text via `aui.composer().setText`
// before the core binds, and the popout refactor (#49488) widened that window,
// so the throw surfaced as an uncaught error that wedged the input.
//
// The fix wraps every `aui.composer().setText` call in a `setComposerText`
// helper that swallows the unbound-core throw — the contentEditable DOM +
// draftRef already hold the text and the draft⇄editor sync re-applies it once
// the core attaches, so nothing is lost. This Harness mirrors that helper
// faithfully (same try/catch shape) over a fake `aui` whose composer can be
// toggled bound/unbound, the way the assistant-ui runtime behaves across mount.
interface FakeComposer {
setText: (value: string) => void
}
// Mirror of index.tsx's `useAui()` composer surface: composer() returns a
// runtime whose setText throws exactly like @assistant-ui/core when unbound.
function makeFakeAui(bound: { current: boolean }, applied: string[]) {
const composer: FakeComposer = {
setText(value: string) {
if (!bound.current) {
throw new Error('Composer is not available')
}
applied.push(value)
}
}
return { composer: () => composer }
}
function Harness({
bound,
applied,
onError
}: {
applied: string[]
bound: { current: boolean }
onError: (err: unknown) => void
}) {
const aui = useRef(makeFakeAui(bound, applied)).current
// Verbatim mirror of the production `setComposerText` helper in index.tsx.
const setComposerText = useCallback(
(value: string) => {
try {
aui.composer().setText(value)
} catch {
// Composer core not bound yet — swallow so the input stays usable.
}
},
[aui]
)
// A draft-restore-on-mount that fires while the core may still be unbound,
// exactly like loadIntoComposer/clearDraft do on startup.
try {
setComposerText('restored draft')
} catch (err) {
onError(err)
}
return null
}
describe('setComposerText guard (#49903)', () => {
it('swallows the unbound-core throw at startup instead of crashing the renderer', () => {
const applied: string[] = []
const bound = { current: false }
const onError = vi.fn()
expect(() => render(<Harness applied={applied} bound={bound} onError={onError} />)).not.toThrow()
// The guard absorbed the throw — nothing escaped to the renderer, and no
// assistant-ui write landed (core was unbound).
expect(onError).not.toHaveBeenCalled()
expect(applied).toEqual([])
})
it('writes through to the composer once the core is bound', () => {
const applied: string[] = []
const bound = { current: true }
const onError = vi.fn()
act(() => {
render(<Harness applied={applied} bound={bound} onError={onError} />)
})
expect(onError).not.toHaveBeenCalled()
expect(applied).toEqual(['restored draft'])
})
})

View File

@@ -13,7 +13,6 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -43,23 +42,22 @@ export function ContextMenu({
return (
<>
<DropdownMenu>
<Tip label={state.tools.label} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="add" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(
GHOST_ICON_BTN,
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
)}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Codicon name="add" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
{c.attachLabel}

View File

@@ -34,7 +34,6 @@ interface InsertRefsDetail {
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
let activeTarget: ComposerTarget = 'main'
@@ -106,13 +105,6 @@ export const requestComposerInsertRefs = (
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/** Toggle the active composer's voice conversation — the `composer.voice`
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
export const onComposerVoiceToggleRequest = (handler: () => void) =>
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
/**
* Focus a composer input across React commit + browser focus restore.
*

View File

@@ -79,8 +79,7 @@ import {
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest,
onComposerVoiceToggleRequest
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
@@ -194,32 +193,6 @@ export function ChatBar({
}: ChatBarProps) {
const aui = useAui()
const draft = useAuiState(s => s.composer.text)
// assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not
// available" when the thread's composer core isn't bound yet — and unlike the
// read path (`s.composer.text`, which is null-safe), there's no graceful
// fallback. There's a startup/thread-swap window where this ChatBar's mount
// effects (draft restore, clearDraft, external inserts) run before the core
// binds; the popout refactor (#49488) widened it by moving the composer out
// of the contain wrapper into a sibling of the thread, so the throw began
// surfacing as an uncaught error that wedged the desktop input (#49903).
//
// Guard every mutation: if the core isn't ready, no-op the assistant-ui write.
// The contentEditable DOM + draftRef already hold the text, and the
// draft⇄editor sync reconciles composer state once the core attaches, so the
// draft is never lost — only the (premature) state push is skipped.
const setComposerText = useCallback(
(value: string) => {
try {
aui.composer().setText(value)
} catch {
// Composer core not bound yet — DOM/draftRef carry the text; the sync
// effect re-applies it after bind. Swallow so the input stays usable.
}
},
[aui]
)
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
@@ -397,7 +370,7 @@ export function ChatBar({
const next = `${base}${sep}${value}`
draftRef.current = next
setComposerText(next)
aui.composer().setText(next)
const editor = editorRef.current
@@ -408,7 +381,7 @@ export function ChatBar({
setFocusRequestId(id => id + 1)
},
[setComposerText]
[aui]
)
useEffect(() => {
@@ -618,7 +591,7 @@ export function ChatBar({
const nextDraft = `${currentDraft}${sep}${text}`
draftRef.current = nextDraft
setComposerText(nextDraft)
aui.composer().setText(nextDraft)
// Push the new text into the contentEditable editor directly. Setting the
// assistant-ui composer state alone is not enough: the draft→editor sync
@@ -651,7 +624,7 @@ export function ChatBar({
}
draftRef.current = nextDraft
setComposerText(nextDraft)
aui.composer().setText(nextDraft)
requestMainFocus()
return true
@@ -737,7 +710,7 @@ export function ChatBar({
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
setComposerText(nextDraft)
aui.composer().setText(nextDraft)
}
window.setTimeout(refreshTrigger, 0)
@@ -863,7 +836,7 @@ export function ChatBar({
renderComposerContents(editor, prefix)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
aui.composer().setText(draftRef.current)
closeTrigger()
runAction()
requestMainFocus()
@@ -891,7 +864,7 @@ export function ChatBar({
const finish = () => {
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
aui.composer().setText(draftRef.current)
requestMainFocus()
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
@@ -1343,17 +1316,17 @@ export function ChatBar({
}
const clearDraft = useCallback(() => {
setComposerText('')
aui.composer().setText('')
draftRef.current = ''
if (editorRef.current) {
editorRef.current.replaceChildren()
}
}, [setComposerText])
}, [aui])
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
draftRef.current = text
setComposerText(text)
aui.composer().setText(text)
$composerAttachments.set(cloneAttachments(attachments))
const editor = editorRef.current
@@ -1726,7 +1699,7 @@ export function ChatBar({
if (domText !== draftRef.current) {
draftRef.current = domText
setComposerText(domText)
aui.composer().setText(domText)
}
}
@@ -1845,24 +1818,6 @@ export function ChatBar({
pendingResponse
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
// with STT unconfigured lets the conversation surface its own "configure
// speech-to-text" notice rather than silently no-opping.
const toggleVoiceConversation = useCallback(() => {
if (disabled) {
return
}
if (voiceConversationActive) {
setVoiceConversationActive(false)
void conversation.end()
} else {
setVoiceConversationActive(true)
}
}, [conversation, disabled, voiceConversationActive])
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
const contextMenu = (
<ContextMenu
onInsertText={insertText}

View File

@@ -5,7 +5,6 @@ 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 { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
@@ -75,36 +74,34 @@ export function ModelPill({
if (!model.modelMenuContent) {
return (
<Tip label={copy.openModelPicker} side="top">
<Button
aria-label={copy.openModelPicker}
className={pillClass}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
type="button"
variant="ghost"
>
{label}
</Button>
</Tip>
<Button
aria-label={copy.openModelPicker}
className={pillClass}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
type="button"
variant="ghost"
>
{label}
</Button>
)
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
disabled={disabled}
type="button"
variant="ghost"
>
{label}
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
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}

View File

@@ -20,7 +20,6 @@ import {
Clock,
Cpu,
Download,
Egg,
Globe,
type IconComponent,
Info,
@@ -30,7 +29,6 @@ import {
Moon,
Package,
Palette,
PawPrint,
Plus,
RefreshCw,
Settings,
@@ -42,9 +40,8 @@ import {
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { runGatewayRestart } from '@/store/system-actions'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@@ -67,7 +64,6 @@ 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. */
@@ -211,7 +207,6 @@ 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()
@@ -257,14 +252,6 @@ 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
@@ -404,20 +391,6 @@ 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'
},
{
icon: Egg,
id: 'appearance-generate-pet',
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
label: cc.generatePet.title,
run: () => openPetGenerate()
}
]
},
@@ -586,12 +559,6 @@ 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': {
@@ -662,57 +629,49 @@ export function CommandPalette() {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
) : page === 'install-theme' ? (
{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
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>
))}
</>
<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>
))}
</CommandList>
</Command>
</DialogPrimitive.Content>

View File

@@ -1,212 +0,0 @@
/**
* 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, Egg, 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
/** Navigate to the "generate a pet" page (rendered as a header action). */
onGenerate?: () => void
}
export function PetPalettePage({ search, onGenerate }: 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">
{onGenerate && (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
HUD_ITEM,
HUD_TEXT
)}
onClick={onGenerate}
onMouseDown={event => event.preventDefault()}
type="button"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
<Egg className="size-4" />
</span>
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
</button>
)}
{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="flex items-center gap-1.5">
<span className="truncate font-medium">{pet.displayName}</span>
{pet.generated && (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
{copy.generatedTag}
</span>
)}
</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>
)
}

View File

@@ -41,8 +41,6 @@ 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,
@@ -54,7 +52,6 @@ import {
} from '../store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$currentCwd,
$freshDraftReady,
$gatewayState,
@@ -108,7 +105,6 @@ import { useKeybinds } from './hooks/use-keybinds'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
@@ -845,53 +841,6 @@ 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 => {
@@ -1029,7 +978,6 @@ export function DesktopController() {
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
{settingsOpen && (

View File

@@ -40,13 +40,6 @@ import {
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
// Settings / sign in / switch to local — the "lost connection breaks the app"
// dead end. The next successful reconnect clears it.
const RECONNECT_ESCALATE_AFTER = 6
interface GatewayBootOptions {
handleGatewayEvent: (event: RpcEvent) => void
onConnectionReady: (
@@ -112,10 +105,6 @@ export function useGatewayBoot({
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
// identical error toasts (and their haptics). Reset on the next clean open.
let reauthNotified = false
// Raised once the reconnect loop crosses RECONNECT_ESCALATE_AFTER so the
// recovery overlay replaces the dead-end CONNECTING screen. Reset on a clean
// open or a manual/wake-driven reconnect.
let escalated = false
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
// `connectionState` to a constant across the early-return guards (the state
@@ -182,11 +171,6 @@ export function useGatewayBoot({
reconnecting = false
if (!cancelled && !gatewayOpen()) {
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
escalated = true
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
}
scheduleReconnect()
}
}
@@ -213,7 +197,6 @@ export function useGatewayBoot({
clearReconnectTimer()
reconnectAttempt = 0
escalated = false
reconnectSecondaryGateways()
if (!gatewayOpen()) {
@@ -247,7 +230,6 @@ export function useGatewayBoot({
if (st === 'open') {
reconnectAttempt = 0
reauthNotified = false
escalated = false
clearReconnectTimer()
// A revalidate-driven reconnect can rebuild the backend in place when the

View File

@@ -94,7 +94,7 @@ export function useGatewayRequest() {
}, [])
const requestGateway = useCallback(
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
async <T>(method: string, params: Record<string, unknown> = {}) => {
const gateway = gatewayRef.current
if (!gateway) {
@@ -102,7 +102,7 @@ export function useGatewayRequest() {
}
try {
return await gateway.request<T>(method, params, timeoutMs, signal)
return await gateway.request<T>(method, params)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -128,7 +128,7 @@ export function useGatewayRequest() {
throw error
}
return recovered.request<T>(method, params, timeoutMs, signal)
return recovered.request<T>(method, params)
}
},
[ensureGatewayOpen]

View File

@@ -40,7 +40,7 @@ import {
import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
import { requestComposerFocus } from '../chat/composer/focus'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import {
AGENTS_ROUTE,
@@ -114,7 +114,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'composer.focus': () => requestComposerFocus('main'),
'composer.modelPicker': () => setModelPickerOpen(true),
'composer.voice': requestVoiceToggle,
'nav.commandPalette': toggleCommandPalette,
'nav.commandCenter': deps.toggleCommandCenter,

View File

@@ -1,19 +0,0 @@
import { useLocation } from 'react-router-dom'
import { appViewForPath, isOverlayView } from '@/app/routes'
/**
* True while a full-screen route overlay (settings, agents, command-center, …)
* is showing.
*
* A portaled Radix modal sits above the app shell, so it would cover such a
* route. Any modal that sends the user to one (e.g. "set up image generation" →
* `/settings`) can `if (useRouteOverlayActive()) return null` to *yield* the
* screen — its open state lives in a store, so it stays open — and reappear,
* re-running its mount effects (a free refresh), when the route overlay closes.
*/
export function useRouteOverlayActive(): boolean {
const { pathname } = useLocation()
return isOverlayView(appViewForPath(pathname))
}

View File

@@ -1,89 +0,0 @@
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { PawPrint } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
const VARIANT_COUNT = 4
interface DraftGridProps {
drafts: { index: number; dataUri: string }[]
generating: boolean
hasDrafts: boolean
onCancel: () => void
onHatch: () => void
onSelect: (index: number) => void
selected: number | null
}
export function DraftGrid({ drafts, generating, hasDrafts, onCancel, onHatch, onSelect, selected }: DraftGridProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const slots = generating
? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null)
: drafts
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span className={cn(generating && 'shimmer shimmer-color-primary opacity-40', !generating && 'invisible')}>
{copy.generating}
</span>
<span className="tabular-nums">
{Math.min(drafts.length, VARIANT_COUNT)}/{VARIANT_COUNT}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{slots.map((draft, i) => {
// A streamed draft is selectable immediately — even mid-generation —
// so the user can commit to one without waiting for the rest.
const isSelected = draft != null && selected === draft.index
return (
<button
className={cn(
'relative flex aspect-[192/208] items-center justify-center overflow-hidden',
selectableCardClass({ active: isSelected, prominent: true })
)}
disabled={draft == null}
key={draft ? `draft-${draft.index}` : `slot-${i}`}
onClick={() => draft != null && onSelect(draft.index)}
type="button"
>
{draft != null ? (
// Hatches into place as each draft streams back.
<img
alt=""
className="pet-reveal size-full object-contain p-1.5"
draggable={false}
src={draft.dataUri}
/>
) : (
// Incubating: a creme egg bouncing on its contact shadow.
<div className="relative z-10 flex flex-col items-center">
<PixelEggSprite index={i} mode="bounce" size={48} />
<span className="pet-egg-shadow pet-egg-shadow--sm" style={{ marginTop: '-0.3rem' }} />
</div>
)}
</button>
)
})}
</div>
{/* Same abort/go-back text link in both states (sits right under the grid);
once drafts land, the full-width Hatch drops in below it. */}
<Button className="self-center" onClick={onCancel} size="xs" variant="text">
{t.common.cancel}
</Button>
{hasDrafts && (
<Button className="w-full" disabled={selected === null} onClick={onHatch}>
<PawPrint />
{copy.hatch}
</Button>
)}
</div>
)
}

View File

@@ -1,27 +0,0 @@
import { Button } from '@/components/ui/button'
interface EmptyHintProps {
onExample: (prompt: string) => void
}
// Creative seed prompts — specifics make better pets (petdex's own advice).
// Short chips that wrap into a tight, centered cluster (capped width → 2 rows).
const EXAMPLE_PROMPTS = ['bubble-tea otter', 'sock elf', 'pixel dragon', 'office cat', 'neon axolotl', 'moss golem']
export function EmptyHint({ onExample }: EmptyHintProps) {
return (
<div className="flex max-w-[300px] flex-wrap place-content-center place-items-center gap-2">
{EXAMPLE_PROMPTS.map(example => (
<Button
className="h-auto w-fit rounded-full font-normal"
key={example}
onClick={() => onExample(`a ${example}`)}
size="xs"
variant="outline"
>
{example}
</Button>
))}
</div>
)
}

View File

@@ -1,52 +0,0 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/external-link'
import { PawPrint, Settings2 } from '@/lib/icons'
interface GenerateUnavailableProps {
onSetup: () => void
}
// Shown when no reference-capable image backend is configured: generation is
// impossible, so we replace the prompt entirely with a friendly path to set one
// up (in-app) plus where to grab a key.
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
return (
<div className="flex flex-col items-center gap-4 text-center">
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
<PawPrint className="size-5" />
</span>
<div className="space-y-1.5">
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">Add an image backend to generate</p>
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
Hatching a custom pet needs a provider that can ground on a reference image.
</p>
</div>
<Button onClick={onSetup} size="sm">
<Settings2 className="size-4" />
Set up image generation
</Button>
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
<span>Grab a key from</span>
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
Nous Portal
</ExternalLink>
<span>·</span>
<ExternalLink
className="opacity-40 transition-opacity hover:opacity-100"
href="https://openrouter.ai/keys"
showExternalIcon={false}
>
OpenRouter
</ExternalLink>
<span>·</span>
<ExternalLink
className="opacity-40 transition-opacity hover:opacity-100"
href="https://platform.openai.com/api-keys"
showExternalIcon={false}
>
OpenAI
</ExternalLink>
</p>
</div>
)
}

View File

@@ -1,137 +0,0 @@
import { useEffect, useState } from 'react'
import { PetSprite } from '@/components/pet/pet-sprite'
import { PetStarShower } from '@/components/pet/pet-star-shower'
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, RefreshCw } from '@/lib/icons'
import { type PetInfo } from '@/store/pet'
import { frameCountForRow } from '../lib/frame-count'
const PREVIEW_SCALE = 0.7
const PREVIEW_STATE_MS = 1400
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
interface HatchPreviewProps {
pet: PetInfo
adopting: boolean
error: string | null
onAdopt: (name: string) => void
onDiscard: () => void
}
export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
// Empty so the "Name your pet" placeholder shows; blank adopt keeps the
// provisional name from the prompt.
const [name, setName] = useState('')
// Play the egg's crack/hatch frames once before swapping in the live pet.
const [revealed, setRevealed] = useState(false)
// Right after the egg cracks the pet plays its "yay" jump a couple times, then
// hands off to the normal state-cycling preview.
const [celebrating, setCelebrating] = useState(false)
const [stateIndex, setStateIndex] = useState(0)
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
const rows = previewRows.length > 0 ? previewRows : ['idle']
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
const canJump = frameCountForRow(pet, 'jumping') > 0
const rowOverride = celebrating && canJump ? 'jumping' : activeRow
useEffect(() => {
const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS)
return () => clearInterval(id)
}, [rows.length])
// On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview.
useEffect(() => {
if (!revealed) {
return
}
setCelebrating(true)
const id = setTimeout(() => {
setCelebrating(false)
setStateIndex(0)
}, 2 * (pet.loopMs ?? 1100))
return () => clearTimeout(id)
}, [revealed, pet.loopMs])
useEffect(() => {
setStateIndex(0)
setName('')
setRevealed(false)
setCelebrating(false)
}, [pet.slug])
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
return (
<div className="flex flex-col items-center gap-2">
{/* Fills the (now narrow) dialog so the pet frame is the screen width. */}
<div className="relative flex aspect-[192/208] w-full items-center justify-center overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)">
{revealed ? (
<>
<div className="relative inline-block">
<span aria-hidden className="pet-contact-shadow" />
<div className="pet-reveal relative z-10">
<PetSprite info={previewInfo} rowOverride={rowOverride} />
</div>
</div>
<PetStarShower />
</>
) : (
// The egg cracks open, then we swap in the live pet.
<PixelEggSprite
mode="hatch"
onDone={() => {
setRevealed(true)
triggerHaptic('crisp')
}}
size={150}
/>
)}
</div>
<Input
autoFocus
className="w-full"
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
onAdopt(name)
}
}}
placeholder={copy.namePlaceholder}
value={name}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex w-full items-center gap-1.5">
<Button disabled={adopting} onClick={onDiscard} variant="ghost">
<RefreshCw />
{copy.startOver}
</Button>
<Button className="flex-1" disabled={adopting} onClick={() => onAdopt(name)}>
{adopting ? <Loader2 className="animate-spin" /> : <PawPrint />}
{copy.adopt}
</Button>
</div>
</div>
)
}

View File

@@ -1,24 +0,0 @@
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
import { useI18n } from '@/i18n'
import { cancelHatch, type PetHatchStage } from '@/store/pet-generate'
interface HatchingViewProps {
stage: PetHatchStage | null
}
// The hatch progress screen — a beating egg with a phase-tracking subtitle
// (per-row → composing → saving).
export function HatchingView({ stage }: HatchingViewProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const subtitle = stage
? stage.phase === 'row'
? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0)
: stage.phase === 'compose'
? copy.hatchComposing
: copy.hatchSaving
: copy.hatchingSub
return <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
}

View File

@@ -1,53 +0,0 @@
import { useStore } from '@nanostores/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Check, ChevronDown } from '@/lib/icons'
import { $petGenProvider, $petGenProviders, setPetGenProvider } from '@/store/pet-generate'
// Image-backend picker for pet generation — the composer's model-pill pattern:
// a quiet trigger + a dropdown of options, each with a one-line speed/quality
// note. Hidden unless there are 2+ reference-capable backends (nothing to pick).
export function ProviderPicker() {
const providers = useStore($petGenProviders)
const picked = useStore($petGenProvider)
if (providers.length < 2) {
return null
}
const fallback = providers.find(p => p.default) ?? providers[0]
const current = providers.find(p => p.name === picked) ?? fallback
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Plain text affordance (matches "Add a reference"), not a padded pill. */}
<button
className="flex h-6 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
type="button"
>
{current?.label}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
{/* The picker lives inside the pet-gen Dialog (z-130) and portals to body,
so lift its menu above the dialog or it opens behind it. */}
<DropdownMenuContent align="start" className="z-[140] w-56">
{providers.map(provider => (
<DropdownMenuItem
className="flex-col items-start gap-0.5"
key={provider.name}
// Picking the default clears the override (no need to pin it).
onSelect={() => setPetGenProvider(provider.default ? '' : provider.name)}
>
<span className="flex w-full items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-foreground">{provider.label}</span>
{provider.name === current?.name && <Check className="size-3.5 text-primary" />}
</span>
{provider.note && <span className="text-[0.6875rem] text-(--ui-text-tertiary)">{provider.note}</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,48 +0,0 @@
import { useState } from 'react'
import { ImageLightbox } from '@/components/chat/zoomable-image'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { X } from '@/lib/icons'
interface ReferenceChipProps {
name: string
onRemove: () => void
src: string
}
// The reference photo as an attachment chip: filename + thumbnail that opens
// the shared image viewer (lightbox), with a remove affordance.
export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
const { t } = useI18n()
const { download, saving } = useImageDownload(src)
const [viewing, setViewing] = useState(false)
return (
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
</button>
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
<button
aria-label="Remove reference"
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
onClick={onRemove}
type="button"
>
<X className="size-3" />
</button>
<ImageLightbox
alt={name}
copy={t.desktop}
onClick={download}
onOpenChange={setViewing}
open={viewing}
saving={saving}
src={src}
/>
</div>
)
}

View File

@@ -1,26 +0,0 @@
import { type PetInfo } from '@/store/pet'
// Sprite row → the PetInfo frame-count key it resolves to (directional walks and
// aliases collapse onto their base state).
const ROW_TO_FRAME_KEY: Record<string, string> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
failed: 'failed',
review: 'review',
waiting: 'waiting'
}
// Real frame count for a row, preferring the concrete per-row count, then the
// per-state count, then the mapped base state, then the sheet-wide default.
export function frameCountForRow(pet: PetInfo, row: string): number {
const mapped = ROW_TO_FRAME_KEY[row]
return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
}

View File

@@ -1,49 +0,0 @@
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024
function loadImage(url: string): Promise<HTMLImageElement> {
const img = new Image()
return new Promise((resolve, reject) => {
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('unreadable image'))
img.src = url
})
}
// Read an image file as a downscaled PNG data URL. We decode from an object URL
// (not readAsDataURL) so large files don't inflate into giant base64 strings
// before we scale them down for generation.
export async function readReferenceImage(
file: File,
max = 1024,
maxInputBytes = DEFAULT_MAX_INPUT_BYTES
): Promise<string> {
if (file.size > maxInputBytes) {
throw new Error('reference image too large')
}
const objectUrl = URL.createObjectURL(file)
try {
const img = await loadImage(objectUrl)
const scale = Math.min(1, max / Math.max(img.width, img.height))
const width = Math.max(1, Math.round(img.width * scale))
const height = Math.max(1, Math.round(img.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('could not create canvas context')
}
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/png')
} finally {
URL.revokeObjectURL(objectUrl)
}
}

View File

@@ -1,293 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { SETTINGS_ROUTE } from '@/app/routes'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Egg, ImageIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petGenAvailable,
$petGenDrafts,
$petGenError,
$petGenInput,
$petGenPreview,
$petGenRefImage,
$petGenRefName,
$petGenSelected,
$petGenStage,
$petGenStatus,
adoptHatched,
cancelGenerate,
checkPetGenAvailable,
cleanPetName,
closePetGenerate,
discardDrafts,
discardHatched,
generateDrafts,
hatchSelected
} from '@/store/pet-generate'
import { DraftGrid } from './components/draft-grid'
import { EmptyHint } from './components/empty-hint'
import { GenerateUnavailable } from './components/generate-unavailable'
import { HatchPreview } from './components/hatch-preview'
import { HatchingView } from './components/hatching-view'
import { ProviderPicker } from './components/provider-picker'
import { ReferenceChip } from './components/reference-chip'
import { readReferenceImage } from './lib/read-reference-image'
// The generate → hatch → adopt controller. A thin view over the `pet-generate`
// store; the store owns the steps and persists inputs across close/reopen.
export function PetGenerateContent() {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
const { requestGateway } = useGatewayRequest()
const navigate = useNavigate()
const status = useStore($petGenStatus)
const error = useStore($petGenError)
const available = useStore($petGenAvailable)
// `null` = not yet probed → stay optimistic (show the prompt); only the
// confirmed-no-backend case swaps in the setup card.
const unavailable = available === false
const drafts = useStore($petGenDrafts)
const selected = useStore($petGenSelected)
const preview = useStore($petGenPreview)
const stage = useStore($petGenStage)
// Inputs live in atoms so they survive a close/reopen (and background runs).
const prompt = useStore($petGenInput)
const refImage = useStore($petGenRefImage)
const refName = useStore($petGenRefName)
const fileRef = useRef<HTMLInputElement>(null)
// Probe backend availability on open — and again whenever the content
// remounts (e.g. after returning from the providers settings), so adding a
// key flips the setup card to the prompt with no manual refresh.
useEffect(() => {
void checkPetGenAvailable(requestGateway)
}, [requestGateway])
const busy = status === 'generating' || status === 'hatching'
const hasDrafts = drafts.length > 0
const generating = status === 'generating'
// The idle "describe a pet" state — egg + suggestions get generous, equidistant
// breathing room (gap-4) from the prompt; the working states stay compact.
const isEmptyState =
!hasDrafts &&
!generating &&
status !== 'hatching' &&
status !== 'preview' &&
status !== 'adopting' &&
status !== 'stale'
const generate = () => {
if ((prompt.trim() || refImage) && !busy) {
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: refImage ?? undefined })
}
}
const clearReference = () => {
$petGenRefImage.set(null)
$petGenRefName.set('')
}
const pickReference = (file: File | undefined) => {
if (!file) {
return
}
const mapReferenceError = (reason: unknown): string => {
const message = reason instanceof Error ? reason.message.toLowerCase() : ''
return message.includes('too large') ? copy.referenceImageTooLarge : copy.referenceImageInvalid
}
void readReferenceImage(file)
.then(dataUrl => {
$petGenRefImage.set(dataUrl)
$petGenRefName.set(file.name)
// Clear picker-only errors once the reference is valid again.
if ($petGenStatus.get() === 'error' && $petGenDrafts.get().length === 0) {
$petGenStatus.set('idle')
$petGenError.set(null)
}
})
.catch(reason => {
$petGenRefImage.set(null)
$petGenRefName.set('')
$petGenError.set(mapReferenceError(reason))
if (!busy) {
$petGenStatus.set('error')
}
})
}
// One-click an example prompt straight into a draft round.
const runExample = (example: string) => {
$petGenInput.set(example)
void generateDrafts(requestGateway, { prompt: example })
}
// Hatch the selected draft. The user can pick one before the rest stream in —
// if so, abort the remaining generations first (keeping the drafts we have).
// The prompt is grounding text, not a label; the user names it on reveal.
const hatch = () => {
if (selected === null) {
return
}
if (generating) {
cancelGenerate()
}
void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() })
}
const adopt = (finalName: string) => {
void adoptHatched(requestGateway, finalName).then(out => {
if (out.ok) {
triggerHaptic('crisp')
closePetGenerate()
}
})
}
// The header title tracks the phase instead of sticking on "Generate a pet".
const headerTitle =
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
// Send the user to set up a key without closing — the overlay yields to the
// settings route (useRouteOverlayActive) and reappears + re-checks on return.
const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`)
// Prompt input only belongs on the describe/draft screens (and never when
// there's no backend to generate with).
const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting'
return (
<>
{unavailable ? (
<DialogTitle className="sr-only">{copy.title}</DialogTitle>
) : (
<DialogHeader>
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
</DialogHeader>
)}
<div className={cn('flex min-h-0 flex-1 flex-col', isEmptyState ? 'gap-4' : 'gap-2.5')}>
{/* Concept prompt with the inline sparkle generate/stop affordance (the
same primitive as the commit-message + project-idea fields). */}
{showPrompt && (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Input
autoFocus
className="pr-9"
onChange={event => $petGenInput.set(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
generate()
}
}}
placeholder={copy.placeholder}
value={prompt}
/>
<GenerateButton
className="absolute right-1 top-1/2 -translate-y-1/2"
disabled={!prompt.trim() && !refImage}
generating={generating}
generatingLabel={t.common.cancel}
label={copy.generate}
// Inline cancel should match step-2 cancel semantics: abort and
// return to step 1 (prompt retained for quick tweaks).
onCancel={discardDrafts}
onGenerate={generate}
/>
</div>
<div className="flex items-center gap-2">
<ProviderPicker />
{refImage ? (
<ReferenceChip name={refName} onRemove={clearReference} src={refImage} />
) : (
<button
className="ml-auto flex h-6 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
onClick={() => fileRef.current?.click()}
type="button"
>
<ImageIcon className="size-3" />
Add a reference
</button>
)}
</div>
{/* Optional reference photo — make a pet from the user's own image.
Styled like the chat composer's attachment pill. */}
<Input
accept="image/*"
className="hidden"
onChange={event => {
pickReference(event.target.files?.[0])
event.target.value = ''
}}
ref={fileRef}
type="file"
/>
</div>
)}
{/* Hatch failed but the drafts are still here — show why above the grid so
the user can re-pick and retry without losing their options. */}
{status === 'error' && hasDrafts && (
<Alert variant="destructive">
<AlertDescription>{error || copy.genericError}</AlertDescription>
</Alert>
)}
{unavailable ? (
<GenerateUnavailable onSetup={setupImageGen} />
) : status === 'stale' ? (
<Alert variant="destructive">
<AlertDescription>{copy.staleBackend}</AlertDescription>
</Alert>
) : status === 'hatching' ? (
<HatchingView stage={stage} />
) : (status === 'preview' || status === 'adopting') && preview ? (
<HatchPreview
adopting={status === 'adopting'}
error={error}
onAdopt={adopt}
onDiscard={() => void discardHatched(requestGateway)}
pet={preview}
/>
) : !hasDrafts && !generating ? (
// Doubles as the error-empty state — the failure reason rides the
// dialog's footer banner, so here we just offer the retry sparks.
<EmptyHint onExample={runExample} />
) : (
<DraftGrid
drafts={drafts}
generating={generating}
hasDrafts={hasDrafts}
onCancel={discardDrafts}
onHatch={hatch}
onSelect={index => $petGenSelected.set(index)}
selected={selected}
/>
)}
</div>
</>
)
}

View File

@@ -1,85 +0,0 @@
/**
* "Hatch a Pet" — a dedicated, Pokédex-style overlay for pet generation.
*
* Previously generation lived as a cramped nested page inside the Cmd-K command
* palette (~34rem popover). This is its own full Radix dialog with room to
* breathe: a device-framed header, its own concept prompt, a roomy draft grid
* that streams in live, and the egg-hatch + reveal flow. It's a thin view over
* the `pet-generate` store; the store owns the generate → hatch → adopt steps.
*
* This file is just the dialog shell + sizing; the flow lives in
* `PetGenerateContent`, and each screen is its own atomic component under
* `./components`.
*/
import { useStore } from '@nanostores/react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
$petGenDrafts,
$petGenerateOpen,
$petGenError,
$petGenStatus,
cleanupPetGenOnClose,
closePetGenerate
} from '@/store/pet-generate'
import { PetGenerateContent } from './pet-generate-content'
export function PetGenerateOverlay() {
const { t } = useI18n()
const { requestGateway } = useGatewayRequest()
const open = useStore($petGenerateOpen)
const status = useStore($petGenStatus)
const error = useStore($petGenError)
const drafts = useStore($petGenDrafts)
// Yield the screen to a full-screen route overlay (e.g. /settings while the
// user adds an image-gen key) without tearing down — the store keeps us open,
// and we reappear + re-check on return.
if (useRouteOverlayActive()) {
return null
}
const handleOpenChange = (next: boolean) => {
if (!next) {
cleanupPetGenOnClose(requestGateway)
// Never interrupt in-flight work. Generating/hatching continues in the
// background; only an unadopted finished preview is discarded on close.
closePetGenerate()
}
}
// The draft screen needs room for the 2×2 grid; the single-pet screens
// (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box.
// `fitContent` lets the dialog size to content; the `min-w` floors each phase.
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
const copy = t.commandCenter.generatePet
// The footer banner narrates the dialog's async state: the failure reason on a
// dead-end error, else the "you can close this, we'll notify you" reassurance
// while a generate/hatch runs in the background.
const working = status === 'generating' || status === 'hatching'
const errored = status === 'error' && drafts.length === 0
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : undefined
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
banner={banner}
bannerTone={errored ? 'error' : 'info'}
// Cap the width so a long banner (e.g. a provider refusal) wraps instead
// of stretching the dialog out; the min-w floors each phase.
className={cn('gap-4 text-center', single ? 'min-w-[17rem] max-w-[20rem]' : 'min-w-[19rem] max-w-[22rem]')}
fitContent
>
{open && <PetGenerateContent />}
</DialogContent>
</Dialog>
)
}

View File

@@ -1,38 +0,0 @@
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>
)
}

View File

@@ -1,345 +0,0 @@
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>
)
}

View File

@@ -5,7 +5,6 @@ import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
@@ -168,41 +167,38 @@ function FilesystemTab({
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
</div>
<Tip label={r.refreshTree} side="left">
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</Tip>
<Tip label={r.openFolder} side="left">
<Button
aria-label={r.openFolder}
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
</Button>
</Tip>
<Tip label={r.collapseAll} side="left">
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
</Tip>
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
title={r.refreshTree}
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
<Button
aria-label={r.openFolder}
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
title={r.openFolder}
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
</Button>
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
title={r.collapseAll}
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}

View File

@@ -34,7 +34,6 @@ 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,
@@ -871,18 +870,10 @@ 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
@@ -904,20 +895,6 @@ 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) {
@@ -930,19 +907,10 @@ 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
@@ -1152,11 +1120,6 @@ export function useMessageStream({
compactedTurnRef.current.delete(sessionId)
}
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: false })
flashPetActivity({ error: true })
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',

View File

@@ -27,7 +27,6 @@ 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,
@@ -41,8 +40,6 @@ import { resetSessionBackground } from '@/store/composer-status'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@@ -62,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
BrowserManageResponse,
ClientSessionState,
BrowserManageResponse,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@@ -555,14 +552,7 @@ export function usePromptActions({
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const usingComposerAttachments = !options?.attachments
// Drop undefined/null holes a session switch or draft restore can leave in
// the attachments array (same bug class as AttachmentList #49624). Without
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
// and break the chat surface.
const attachments = (options?.attachments ?? $composerAttachments.get()).filter(
(a): a is ComposerAttachment => Boolean(a)
)
const attachments = options?.attachments ?? $composerAttachments.get()
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
@@ -575,17 +565,14 @@ export function usePromptActions({
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
// atts may be the post-sync array, which can reintroduce holes; filter
// before touching a.refText / a.kind.
const present = atts.filter((a): a is ComposerAttachment => Boolean(a))
const contextRefs = present
const contextRefs = atts
.map(a => a.refText)
.filter(Boolean)
.join('\n')
return (
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(present.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
)
}
@@ -1189,47 +1176,6 @@ export function usePromptActions({
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /hatch opens the pet generator overlay (the desktop's rich, multi-step
// generate→pick→hatch→adopt flow). A typed description seeds the prompt
// so `/hatch a cyber fox` lands on the composer step prefilled.
hatch: async ({ arg }) => {
const concept = arg.trim()
if (concept) {
$petGenInput.set(concept)
}
openPetGenerate()
},
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
@@ -1446,7 +1392,6 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)

View File

@@ -9,7 +9,6 @@ import {
$busy,
$messages,
noteSessionActivity,
onSessionWatchdogClear,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
@@ -277,31 +276,6 @@ export function useSessionStateCache({
[ensureSessionState, syncSessionStateToView]
)
// When the store watchdog force-clears a stuck session (8 min of stream
// silence — a hung or looping turn that never delivered its terminal event),
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
// re-syncs `$busy` when the healed session is the one on screen.
useEffect(
() =>
onSessionWatchdogClear(storedSessionId => {
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
if (!runtimeId || !state?.busy) {
return
}
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
}),
[updateSessionState]
)
return {
activeSessionIdRef,
ensureSessionState,

View File

@@ -1,31 +1,30 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { 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 { getBaseColors, useTheme } from '@/themes/context'
import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { MODE_OPTIONS } from './constants'
import { PetSettings } from './pet-settings'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
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)
function ThemePreview({ name }: { name: string }) {
const t = resolveTheme(name)
if (!t) {
return null
}
const c = t.colors
return (
<div
@@ -58,200 +57,90 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' })
)
}
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
}) {
function VscodeThemeInstaller() {
const { t } = useI18n()
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 { 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 search = useQuery({
enabled: debounced.length > 0,
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]),
queryKey: ['marketplace-themes-settings', debounced],
staleTime: 5 * 60 * 1000
})
const install = async () => {
const trimmed = id.trim()
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
if (!trimmed || busy) {
return
}
setInstallingId(item.extensionId)
setError(null)
setBusy(true)
setStatus(null)
try {
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
const theme = await installVscodeThemeFromMarketplace(trimmed)
triggerHaptic('crisp')
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
onInstalled(theme.name)
} catch (e) {
setError(e instanceof Error ? e.message : copy.error)
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 })
} finally {
setInstallingId(null)
setBusy(false)
}
}
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>
</>
)
}
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 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}
</p>
)}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const { themeName, mode, 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
@@ -274,7 +163,7 @@ export function AppearanceSettings() {
{a.intro}
</p>
<div className="mt-2">
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
@@ -282,107 +171,18 @@ export function AppearanceSettings() {
/>
<ListRow
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>
)}
</>
action={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
}
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
description={a.colorModeDesc}
title={a.colorMode}
/>
<ListRow
@@ -411,6 +211,80 @@ 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
@@ -427,10 +301,6 @@ export function AppearanceSettings() {
/>
</div>
</div>
<div className="mt-6">
<PetSettings />
</div>
</SettingsContent>
)
}

View File

@@ -26,26 +26,6 @@ import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
// provider — otherwise every provider's options render at once (the "totally
// crazy" wall of ~30 fields). Top-level keys (tts.provider, stt.enabled,
// voice.*) always show; STT provider fields hide entirely when STT is off.
export function voiceFieldVisible(key: string, config: HermesConfigRecord): boolean {
const match = /^(tts|stt)\.([^.]+)\./.exec(key)
if (!match) {
return true
}
const [, domain, provider] = match
if (domain === 'stt' && !getNested(config, 'stt.enabled')) {
return false
}
return provider === String(getNested(config, `${domain}.provider`) ?? '')
}
function ConfigField({
schemaKey,
schema,
@@ -376,9 +356,6 @@ export function ConfigSettings({
return <LoadingState label={c.loading} />
}
const visibleFields =
activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
return (
<SettingsContent>
{activeSectionId === 'model' && (
@@ -386,11 +363,11 @@ export function ConfigSettings({
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{visibleFields.length === 0 ? (
{fields.length === 0 ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : (
<div className="grid gap-1">
{visibleFields.map(([key, field]) => (
{fields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
descriptionExtra={

View File

@@ -1,359 +0,0 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Download, Loader2, PawPrint, Pencil, 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,
exportPet as exportPetAction,
loadPetGallery,
loadPetThumb,
PET_SCALE_DEFAULT,
PET_SCALE_MAX,
PET_SCALE_MIN,
type GalleryPet,
rankedGalleryPets,
removePet as removePetAction,
renamePet as renamePetAction,
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 [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
const [renameValue, setRenameValue] = 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 exportPet = (slug: string) => {
void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const saveRename = () => {
if (!renameTarget || !renameValue.trim()) {
return
}
// Optimistic: the rename paints instantly, so close now and let the RPC
// settle in the background (it rolls back + surfaces an error on failure).
const { slug } = renameTarget
setRenameTarget(null)
triggerHaptic('crisp')
void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug))
}
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="flex items-center gap-1.5">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{pet.displayName}
</span>
{pet.generated && (
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
{copy.generatedTag}
</span>
)}
</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>
{!isBusy && (pet.installed || pet.generated) && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100">
{pet.generated && (
<PetAction
icon={<Pencil className="size-3.5" />}
label={copy.rename(pet.displayName)}
onClick={() => {
setRenameValue(pet.displayName)
setRenameTarget(pet)
}}
/>
)}
{pet.generated && (
<PetAction
icon={<Download className="size-3.5" />}
label={copy.exportPet(pet.displayName)}
onClick={() => exportPet(pet.slug)}
/>
)}
{pet.installed && (
// Generated pets have no remote source — deletion is
// permanent, so confirm; petdex pets just uninstall.
<PetAction
danger
icon={<Trash2 className="size-3.5" />}
label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)}
onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))}
/>
)}
</div>
)}
</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>
<ConfirmDialog
confirmLabel={copy.deleteConfirm}
description={copy.deleteBody}
destructive
onClose={() => setConfirmDelete(null)}
onConfirm={async () => {
if (confirmDelete) {
const ok = await removePetAction(requestGateway, confirmDelete.slug, copy.uninstallFailed(confirmDelete.slug))
if (!ok) {
throw new Error(copy.uninstallFailed(confirmDelete.slug))
}
triggerHaptic('crisp')
}
}}
open={confirmDelete !== null}
title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''}
/>
<Dialog onOpenChange={open => !open && setRenameTarget(null)} open={renameTarget !== null}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{copy.renameTitle}</DialogTitle>
</DialogHeader>
<Input
autoFocus
onChange={event => setRenameValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
saveRename()
}
}}
placeholder={copy.renamePlaceholder}
value={renameValue}
/>
<DialogFooter>
<Button onClick={() => setRenameTarget(null)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={!renameValue.trim()} onClick={saveRename}>
{copy.renameSave}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/** A single hover-revealed icon action on a pet card (rename / export / delete). */
function PetAction({
danger,
icon,
label,
onClick
}: {
danger?: boolean
icon: ReactNode
label: string
onClick: () => void
}) {
return (
<button
aria-label={label}
className={cn(
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
)}
onClick={onClick}
title={label}
type="button"
>
{icon}
</button>
)
}

View File

@@ -1,48 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { voiceFieldVisible } from './config-settings'
const cfg = (over: Record<string, unknown> = {}): HermesConfigRecord =>
({
tts: { provider: 'edge', edge: {}, openai: {} },
stt: { enabled: true, provider: 'local', local: {}, groq: {} },
...over
}) as unknown as HermesConfigRecord
describe('voiceFieldVisible', () => {
it('always shows top-level + non-provider keys', () => {
const config = cfg()
for (const key of ['tts.provider', 'stt.enabled', 'stt.provider', 'voice.auto_tts', 'voice.record_key']) {
expect(voiceFieldVisible(key, config)).toBe(true)
}
})
it('shows only the selected TTS provider sub-fields', () => {
const config = cfg()
expect(voiceFieldVisible('tts.edge.voice', config)).toBe(true)
expect(voiceFieldVisible('tts.openai.voice', config)).toBe(false)
expect(voiceFieldVisible('tts.elevenlabs.voice_id', config)).toBe(false)
})
it('shows only the selected STT provider sub-fields', () => {
const config = cfg()
expect(voiceFieldVisible('stt.local.model', config)).toBe(true)
expect(voiceFieldVisible('stt.groq.model', config)).toBe(false)
})
it('hides every STT provider sub-field when STT is disabled', () => {
const config = cfg({ stt: { enabled: false, provider: 'local', local: {} } })
expect(voiceFieldVisible('stt.local.model', config)).toBe(false)
// ...but the enable/provider toggles themselves stay visible.
expect(voiceFieldVisible('stt.enabled', config)).toBe(true)
expect(voiceFieldVisible('stt.provider', config)).toBe(true)
})
it('tracks a provider switch', () => {
expect(voiceFieldVisible('tts.openai.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(true)
expect(voiceFieldVisible('tts.edge.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(false)
})
})

View File

@@ -4,7 +4,6 @@ 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 {
@@ -203,10 +202,6 @@ 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>
)
}

View File

@@ -22,6 +22,8 @@ import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$activeSessionId,
$busy,
@@ -29,10 +31,11 @@ import {
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
$workingSessionIds,
$yoloActive,
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
$backendUpdateApply,
@@ -87,9 +90,12 @@ export function useStatusbarItems({
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const gatewayRestarting = useStore($gatewayRestarting)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
const workingSessionIds = useStore($workingSessionIds)
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
@@ -153,17 +159,24 @@ export function useStatusbarItems({
[gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
)
// The indicator must speak the same scope as the Spawn-tree panel it opens:
// every session's subagents, never background system actions (gateway
// restarts, toolset installs) which surface in their own panels.
const { subagentsFailed, subagentsRunning } = useMemo(() => {
const lists = Object.values(subagentsBySession)
const { bgFailed, bgRunning, subagentsRunning } = useMemo(() => {
const actions = Object.values(desktopActionTasks)
const running = actions.filter(t => t.status.running).length
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
const subagentsRunning = Object.values(subagentsBySession).reduce(
(sum, items) => sum + activeSubagentCount(items),
0
)
return {
subagentsFailed: lists.reduce((sum, items) => sum + failedSubagentCount(items), 0),
subagentsRunning: lists.reduce((sum, items) => sum + activeSubagentCount(items), 0)
bgFailed: failed + previewFailed,
bgRunning: workingSessionIds.length + running + previewRunning,
subagentsRunning
}
}, [subagentsBySession])
}, [desktopActionTasks, previewServerRestartStatus, subagentsBySession, workingSessionIds])
const gatewayOpen = gatewayState === 'open'
const gatewayConnecting = gatewayState === 'connecting'
@@ -308,18 +321,20 @@ export function useStatusbarItems({
{
className: cn(
agentsOpen && 'bg-accent/55 text-foreground',
subagentsFailed > 0 && 'text-destructive hover:text-destructive'
bgFailed > 0 && 'text-destructive hover:text-destructive'
),
detail:
subagentsRunning > 0
? copy.subagents(subagentsRunning)
: subagentsFailed > 0
? copy.failed(subagentsFailed)
: undefined,
: bgFailed > 0
? copy.failed(bgFailed)
: bgRunning > 0
? copy.running(bgRunning)
: undefined,
icon:
subagentsFailed > 0 ? (
bgFailed > 0 ? (
<AlertCircle className="size-3" />
) : subagentsRunning > 0 ? (
) : bgRunning > 0 || subagentsRunning > 0 ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Sparkles className="size-3" />
@@ -341,6 +356,8 @@ export function useStatusbarItems({
],
[
agentsOpen,
bgFailed,
bgRunning,
commandCenterOpen,
copy,
gatewayMenuContent,
@@ -350,7 +367,6 @@ export function useStatusbarItems({
inferenceReady,
inferenceStatus?.reason,
openAgents,
subagentsFailed,
subagentsRunning,
toggleCommandCenter
]

View File

@@ -4,7 +4,6 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -205,43 +204,41 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
if (tool.href) {
return (
<Tip label={tool.title ?? tool.label}>
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
>
{tool.icon}
</a>
</Button>
</Tip>
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
</Button>
)
}
return (
<Tip label={tool.title ?? tool.label}>
<Button
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}
className={className}
disabled={tool.disabled}
onClick={() => {
if (tool.to) {
navigate(tool.to)
}
<Button
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}
className={className}
disabled={tool.disabled}
onClick={() => {
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
type="button"
variant="ghost"
>
{tool.icon}
</Button>
</Tip>
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>
{tool.icon}
</Button>
)
}

View File

@@ -382,8 +382,6 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
const u = t.updates
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
const currentMessage = apply.message.trim()
const recentLog = apply.log.slice(-4)
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
@@ -399,12 +397,6 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
<DialogDescription className="text-center text-sm">
{body}
</DialogDescription>
{currentMessage ? (
<p className="max-w-lg break-words text-center text-xs leading-5 text-muted-foreground">
{currentMessage}
</p>
) : null}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
@@ -417,16 +409,6 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
/>
</div>
{recentLog.length > 1 ? (
<div className="max-h-24 overflow-hidden rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-left font-mono text-[11px] leading-4 text-muted-foreground">
{recentLog.map((entry, index) => (
<div className="truncate" key={`${entry.at}-${index}`}>
{entry.message}
</div>
))}
</div>
) : null}
<p className="text-center text-xs text-muted-foreground">{u.applyingClose}</p>
</div>
)

View File

@@ -1,13 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setRuntimeI18nLocale } from '@/i18n'
import { describe, expect, it } from 'vitest'
import {
buildToolView,
clampForDisplay,
countDiffLineStats,
inlineDiffFromResult,
MAX_TOOL_RENDER_CHARS,
type ToolPart
} from './tool-fallback-model'
@@ -21,10 +17,6 @@ const part = (overrides: Partial<ToolPart>): ToolPart => ({
...overrides
})
afterEach(() => {
setRuntimeI18nLocale('en')
})
describe('buildToolView image handling', () => {
// vision_analyze reports the input image as a local path; an <img> pointed at
// a bare path resolves against the renderer origin and 404s, so we render the
@@ -48,7 +40,8 @@ describe('buildToolView image handling', () => {
})
describe('buildToolView terminal exit-code status', () => {
const terminal = (result: Record<string, unknown>) => buildToolView(part({ result, toolName: 'terminal' }), '')
const terminal = (result: Record<string, unknown>) =>
buildToolView(part({ result, toolName: 'terminal' }), '')
// A non-zero exit code with real output is not a failure (grep no-match,
// diff differences, piped commands surfacing the last stage's code, etc.) —
@@ -117,89 +110,6 @@ describe('buildToolView file edit diffs', () => {
})
})
describe('buildToolView title actions', () => {
it('marks the pending action separately from the rest of the title', () => {
const read = buildToolView(part({ args: { path: '/tmp/demo.txt' }, result: undefined, toolName: 'read_file' }), '')
const web = buildToolView(
part({ args: { url: 'https://example.com/docs' }, result: undefined, toolName: 'web_extract' }),
''
)
const terminal = buildToolView(
part({ args: { command: 'npm test -- --runInBand' }, result: undefined, toolName: 'terminal' }),
''
)
const code = buildToolView(
part({ args: { code: 'print("hello")' }, result: undefined, toolName: 'execute_code' }),
''
)
expect(read.title).toBe('Reading file')
expect(read.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' file' })
expect(web.title).toBe('Reading example.com/docs')
expect(web.titleAction).toEqual({ prefix: '', text: 'Reading', suffix: ' example.com/docs' })
expect(terminal.title).toBe('Running · npm test -- --runInBand')
expect(terminal.titleAction).toEqual({ prefix: '', text: 'Running', suffix: ' · npm test -- --runInBand' })
expect(code.title).toBe('Scripting · print("hello")')
expect(code.titleAction).toEqual({ prefix: '', text: 'Scripting', suffix: ' · print("hello")' })
})
it('does not mark completed tool titles as pending actions', () => {
const view = buildToolView(part({ args: { url: 'https://example.com/docs' }, toolName: 'web_extract' }), '')
expect(view.title).toBe('Read example.com/docs')
expect(view.titleAction).toBeUndefined()
})
it('uses the runtime locale for title text and action placement', () => {
setRuntimeI18nLocale('ja')
const read = buildToolView(part({ args: { path: '/tmp/demo.txt' }, result: undefined, toolName: 'read_file' }), '')
const web = buildToolView(
part({ args: { url: 'https://example.com/docs' }, result: undefined, toolName: 'web_extract' }),
''
)
expect(read.title).toBe('ファイルを読み取り中')
expect(read.titleAction).toEqual({ prefix: 'ファイルを', text: '読み取り中', suffix: '' })
expect(web.title).toBe('example.com/docs を読み取り中')
expect(web.titleAction).toEqual({ prefix: 'example.com/docs を', text: '読み取り中', suffix: '' })
})
})
describe('clampForDisplay', () => {
it('passes short payloads through untouched', () => {
expect(clampForDisplay('hello')).toBe('hello')
expect(clampForDisplay('x'.repeat(MAX_TOOL_RENDER_CHARS))).toHaveLength(MAX_TOOL_RENDER_CHARS)
})
it('truncates oversized payloads and reports the omitted count', () => {
const oversized = 'x'.repeat(MAX_TOOL_RENDER_CHARS + 5_000)
const clamped = clampForDisplay(oversized)
expect(clamped.length).toBeLessThan(oversized.length)
expect(clamped.startsWith('x'.repeat(MAX_TOOL_RENDER_CHARS))).toBe(true)
expect(clamped).toContain('5,000 more characters truncated')
expect(clamped).toContain('Copy')
})
})
// A large tool result (e.g. a 100KB read_file during a `/learn` run) must not
// be serialized into the rendered rawResult at full size — that JSON.stringify
// payload is what floods the renderer when many rows stack up.
describe('buildToolView caps serialized result size', () => {
it('clamps rawResult for an oversized result', () => {
const huge = 'y'.repeat(MAX_TOOL_RENDER_CHARS * 3)
const view = buildToolView(part({ result: { content: huge }, toolName: 'read_file' }), '')
expect(view.rawResult.length).toBeLessThanOrEqual(MAX_TOOL_RENDER_CHARS + 200)
expect(view.rawResult).toContain('truncated')
})
})
describe('countDiffLineStats', () => {
it('counts added and removed lines', () => {
expect(

View File

@@ -1,6 +1,6 @@
import { type ToolTitleKey, translateNow } from '@/i18n'
import { normalizeExternalUrl } from '@/lib/external-link'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
import { translateNow } from '@/i18n'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
@@ -20,12 +20,6 @@ export interface SearchResultRow {
url: string
}
export interface ToolTitleAction {
prefix: string
suffix: string
text: string
}
interface CountMetric {
count: number
noun: string
@@ -57,7 +51,6 @@ export interface ToolView {
status: ToolStatus
subtitle: string
title: string
titleAction?: ToolTitleAction
tone: ToolTone
}
@@ -65,12 +58,6 @@ interface ToolMeta {
done: string
icon?: string
pending: string
pendingAction: string
tone: ToolTone
}
interface ToolMetaSpec {
icon?: string
tone: ToolTone
}
@@ -125,78 +112,44 @@ function fileEditBasename(path: string): string {
return normalized.split('/').filter(Boolean).pop() || normalized
}
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
browser_click: {
icon: 'globe',
tone: 'browser'
},
browser_fill: {
icon: 'globe',
tone: 'browser'
},
browser_navigate: {
icon: 'globe',
tone: 'browser'
},
const TOOL_META: Record<string, ToolMeta> = {
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' },
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' },
browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' },
browser_snapshot: {
done: 'Captured page snapshot',
pending: 'Capturing page snapshot',
icon: 'globe',
tone: 'browser'
},
browser_take_screenshot: {
done: 'Captured screenshot',
pending: 'Capturing screenshot',
icon: 'file-media',
tone: 'browser'
},
browser_type: {
icon: 'globe',
tone: 'browser'
},
clarify: {
icon: 'question',
tone: 'agent'
},
cronjob: {
icon: 'watch',
tone: 'agent'
},
edit_file: { icon: 'edit', tone: 'file' },
execute_code: {
icon: 'terminal',
tone: 'terminal'
},
image_generate: {
icon: 'file-media',
tone: 'image'
},
list_files: {
icon: 'files',
tone: 'file'
},
patch: { icon: 'edit', tone: 'file' },
read_file: { icon: 'file', tone: 'file' },
search_files: {
icon: 'search',
tone: 'file'
},
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
patch: { done: 'Patched file', pending: 'Patching file', icon: 'edit', tone: 'file' },
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
session_search_recall: {
done: 'Searched session history',
pending: 'Searching session history',
icon: 'search',
tone: 'agent'
},
terminal: {
icon: 'terminal',
tone: 'terminal'
},
todo: { icon: 'tools', tone: 'agent' },
vision_analyze: {
icon: 'eye',
tone: 'image'
},
web_extract: { icon: 'globe', tone: 'web' },
web_search: { icon: 'search', tone: 'web' },
write_file: { icon: 'edit', tone: 'file' }
}
function isToolTitleKey(name: string): name is ToolTitleKey {
return name in TOOL_META
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', icon: 'eye', tone: 'image' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
}
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
@@ -218,45 +171,27 @@ function titleForTool(name: string): string {
)
}
const PREFIX_META: { icon?: string; labelKey: string; prefix: string; tone: ToolTone }[] = [
{ prefix: 'browser_', labelKey: 'browser', icon: 'globe', tone: 'browser' },
{ prefix: 'web_', labelKey: 'web', icon: 'globe', tone: 'web' }
const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [
{ prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' },
{ prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' }
]
function toolMeta(name: string): ToolMeta {
if (isToolTitleKey(name)) {
const meta = TOOL_META[name]
return {
done: translateNow(`assistant.tool.titles.${name}.done`),
pending: translateNow(`assistant.tool.titles.${name}.pending`),
pendingAction: translateNow(`assistant.tool.titles.${name}.pendingAction`),
icon: meta.icon,
tone: meta.tone
}
if (TOOL_META[name]) {
return TOOL_META[name]
}
const action = titleForTool(name)
const prefix = PREFIX_META.find(p => name.startsWith(p.prefix))
if (prefix) {
const prefixLabel = translateNow(`assistant.tool.prefixes.${prefix.labelKey}`)
return {
done: translateNow('assistant.tool.titleTemplates.prefixedDone', prefixLabel, action),
pending: translateNow('assistant.tool.titleTemplates.runningPrefixedTool', prefixLabel, action),
pendingAction: translateNow('assistant.tool.actions.running'),
icon: prefix.icon,
tone: prefix.tone
}
}
return {
done: action,
pending: translateNow('assistant.tool.titleTemplates.runningTool', action),
pendingAction: translateNow('assistant.tool.actions.running'),
tone: 'default'
}
return prefix
? {
done: `${prefix.verb} ${action}`,
pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`,
icon: prefix.icon,
tone: prefix.tone
}
: { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' }
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -303,26 +238,8 @@ function contextValue(value: unknown): string {
return typeof value === 'string' ? value : ''
}
// Each tool result is server-capped (~100KB), but a turn over a big directory
// stacks many rows; painting/serializing them all floods the renderer (freeze,
// then OOM). Clamp every inline-painted payload to a bounded slice — the row's
// Copy button still reads the uncapped `view.detail` for the full output.
export const MAX_TOOL_RENDER_CHARS = 20_000
export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string {
if (value.length <= max) {
return value
}
const omitted = value.length - max
return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.`
}
function prettyJson(value: unknown): string {
const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return clampForDisplay(raw ?? '')
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
function parseMaybeObject(value: unknown): Record<string, unknown> {
@@ -1032,13 +949,8 @@ function fallbackDetailText(args: unknown, result: unknown): string {
}
function cronScalar(value: unknown): string {
if (typeof value === 'string') {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return ''
}
@@ -1046,9 +958,7 @@ function cronScalar(value: unknown): string {
function formatCronTime(iso: string): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) {
return iso
}
if (Number.isNaN(ts)) return iso
return new Date(ts).toLocaleString(undefined, {
month: 'short',
@@ -1058,7 +968,10 @@ function formatCronTime(iso: string): string {
})
}
function cronjobSubtitle(argsRecord: Record<string, unknown>, resultRecord: Record<string, unknown>): string {
function cronjobSubtitle(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
@@ -1067,9 +980,7 @@ function cronjobSubtitle(argsRecord: Record<string, unknown>, resultRecord: Reco
const message = firstStringField(resultRecord, ['message'])
if (message) {
return message
}
if (message) return message
const action = firstStringField(argsRecord, ['action']) || 'manage'
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
@@ -1078,13 +989,14 @@ function cronjobSubtitle(argsRecord: Record<string, unknown>, resultRecord: Reco
return name ? `${label} ${name}` : `Cron ${action}`
}
function cronjobDetail(argsRecord: Record<string, unknown>, resultRecord: Record<string, unknown>): string {
function cronjobDetail(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
if (!jobs.length) {
return 'No cron jobs scheduled'
}
if (!jobs.length) return 'No cron jobs scheduled'
return jobs
.slice(0, 20)
@@ -1099,14 +1011,12 @@ function cronjobDetail(argsRecord: Record<string, unknown>, resultRecord: Record
}
const nextRun = cronScalar(resultRecord.next_run_at)
const rows: [string, string][] = [
['Schedule', cronScalar(resultRecord.schedule)],
['Repeat', cronScalar(resultRecord.repeat)],
['Delivery', cronScalar(resultRecord.deliver)],
['Next run', nextRun ? formatCronTime(nextRun) : '']
]
const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
@@ -1349,7 +1259,6 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
url: translateNow('assistant.tool.copyUrl'),
generic: translateNow('common.copy')
}
const args = parseMaybeObject(part.args)
const result = parseMaybeObject(part.result)
const detail = view.detail.trim()
@@ -1432,90 +1341,39 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
return { label: copy.generic, text: view.title }
}
interface ToolTitleParts {
action?: ToolTitleAction
title: string
}
function titlePartsFromAction(title: string, action?: string): ToolTitleParts {
if (!action) {
return { title }
}
const actionStart = title.indexOf(action)
if (actionStart < 0) {
return { title }
}
return {
action: {
prefix: title.slice(0, actionStart),
suffix: title.slice(actionStart + action.length),
text: action
},
title
}
}
function dynamicTitle(
part: ToolPart,
args: Record<string, unknown>,
result: Record<string, unknown>,
fallback: ToolTitleParts
): ToolTitleParts {
fallback: string
): string {
const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past)
const titledAction = (action: string, title: string): ToolTitleParts =>
titlePartsFromAction(title, part.result === undefined ? action : undefined)
if (part.toolName === 'web_extract') {
const url = findFirstUrl(args, result)
const action = verb(translateNow('assistant.tool.actions.reading'), translateNow('assistant.tool.actions.read'))
return url
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
: fallback
return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback
}
if (part.toolName === 'browser_navigate') {
const url = findFirstUrl(args, result)
const action = verb(translateNow('assistant.tool.actions.opening'), translateNow('assistant.tool.actions.opened'))
return url
? titledAction(action, translateNow('assistant.tool.titleTemplates.actionTarget', action, hostnameOf(url)))
: fallback
return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback
}
if (part.toolName === 'web_search') {
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
const action = verb(
translateNow('assistant.tool.actions.searching'),
translateNow('assistant.tool.actions.searched')
)
return query
? titledAction(
action,
translateNow('assistant.tool.titleTemplates.actionQuoted', action, compactPreview(query, 48))
)
: fallback
return query ? `${verb('Searching', 'Searched')}${compactPreview(query, 48)}` : fallback
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
if (command) {
const action =
part.toolName === 'execute_code'
? verb(translateNow('assistant.tool.actions.runningCode'), translateNow('assistant.tool.actions.ranCode'))
: verb(translateNow('assistant.tool.actions.running'), translateNow('assistant.tool.actions.ran'))
const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran')
return titledAction(
action,
translateNow('assistant.tool.titleTemplates.actionCommand', action, compactPreview(command, 160))
)
return `${verbText} · ${compactPreview(command, 160)}`
}
}
@@ -1523,7 +1381,7 @@ function dynamicTitle(
const path = fileEditPath(args, result)
if (path) {
return { title: fileEditBasename(path) }
return fileEditBasename(path)
}
}
@@ -1537,15 +1395,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const status = toolStatus(part, resultRecord)
const error = toolErrorText(part, resultRecord)
const baseTitle = part.result === undefined ? meta.pending : meta.done
const titleParts = dynamicTitle(
part,
argsRecord,
resultRecord,
titlePartsFromAction(baseTitle, part.result === undefined ? meta.pendingAction : undefined)
)
const title = titleParts.title
const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle)
const titleEnriched = title !== baseTitle
const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
@@ -1599,7 +1449,6 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
status,
subtitle,
title,
titleAction: titleParts.action,
tone: meta.tone
}
}

View File

@@ -33,7 +33,6 @@ import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/sto
import { PendingToolApproval } from './tool-approval'
import {
buildToolView,
clampForDisplay,
cleanVisibleText,
countDiffLineStats,
inlineDiffFromResult,
@@ -46,8 +45,7 @@ import {
toolCopyPayload,
type ToolPart,
toolPartDisclosureId,
type ToolStatus,
type ToolTitleAction
type ToolStatus
} from './tool-fallback-model'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
@@ -106,7 +104,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
})
.filter(Boolean)
return clampForDisplay(parts.join('\n'))
return parts.join('\n')
}
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
@@ -204,39 +202,6 @@ function LinkifiedText({ className, text }: { className?: string; text: string }
return <SharedLinkifiedText className={className} pretty text={cleanVisibleText(text)} />
}
function ToolTitle({
isPending,
status,
title,
titleAction
}: {
isPending: boolean
status: ToolStatus
title: string
titleAction?: ToolTitleAction
}) {
return (
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
isPending && 'text-(--ui-text-tertiary)',
status === 'error' && 'text-destructive',
status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{isPending && titleAction ? (
<>
{titleAction.prefix}
<span className="shimmer">{titleAction.text}</span>
{titleAction.suffix}
</>
) : (
title
)}
</FadeText>
)
}
interface ToolEntryProps {
part: ToolPart
}
@@ -255,25 +220,13 @@ function ToolEntry({ part }: ToolEntryProps) {
const messageRunning = useAuiState(selectMessageRunning)
const embedded = useContext(ToolEmbedContext)
const toolViewMode = useStore($toolViewMode)
// `ToolFallback` rebuilds the `part` wrapper each render, defeating the memos
// below and re-running buildToolView (full JSON.stringify of result) on every
// stream delta — the freeze on big `/learn` runs. Re-derive a stable part from
// the referentially-stable args/result so the memos hold across deltas.
const { args, isError, result, toolCallId, toolName } = part
const stablePart = useMemo<ToolPart>(
() => ({ args, isError, result, toolCallId, toolName, type: 'tool-call' }),
[args, isError, result, toolCallId, toolName]
)
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(stablePart)}`
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
const dismissed = useStore($toolRowDismissed(disclosureId))
const isPending = messageRunning && result === undefined
const isPending = messageRunning && part.result === undefined
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
const isFileEdit = isFileEditTool(toolName)
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
const isFileEdit = isFileEditTool(part.toolName)
const defaultOpen = Boolean(inlineDiff)
const open = useDisclosureOpen(disclosureId, defaultOpen)
const canDismiss = !isPending && !embedded
@@ -284,14 +237,13 @@ function ToolEntry({ part }: ToolEntryProps) {
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
// Stale parts (no result, but message stopped running) get a synthetic empty
// result so buildToolView treats them as completed-no-output. Keyed on
// stablePart so it recomputes only when this tool's data changes.
// Stale parts (no result, but message stopped running) get a synthetic
// empty result so buildToolView treats them as completed-no-output.
const view = useMemo(() => {
const p = !isPending && result === undefined ? { ...stablePart, result: {} } : stablePart
const p = !isPending && part.result === undefined ? { ...part, result: {} } : part
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, result, stablePart])
}, [inlineDiff, isPending, part])
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
// in the composer status stack rather than a bulky inline card. Uses the same
@@ -361,9 +313,7 @@ function ToolEntry({ part }: ToolEntryProps) {
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
)
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
// what's painted, so the row's Copy button still yields the full output.
const copyAction = useMemo(() => toolCopyPayload(stablePart, view), [stablePart, view])
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
const diffStats = useMemo(
() => (isFileEdit && view.inlineDiff ? countDiffLineStats(view.inlineDiff) : null),
@@ -448,7 +398,16 @@ function ToolEntry({ part }: ToolEntryProps) {
icon={view.icon}
status={leadingStatus(isPending, view.status)}
/>
<ToolTitle isPending={isPending} status={view.status} title={view.title} titleAction={view.titleAction} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
isPending && 'shimmer text-(--ui-text-tertiary)',
view.status === 'error' && 'text-destructive',
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{view.title}
</FadeText>
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{showDiffStats && diffStats && (
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">
@@ -507,7 +466,7 @@ function ToolEntry({ part }: ToolEntryProps) {
detailSections.summary && 'mt-1.5'
)}
>
{clampForDisplay(detailSections.body)}
{detailSections.body}
</pre>
)}
</div>
@@ -522,7 +481,7 @@ function ToolEntry({ part }: ToolEntryProps) {
<div className="space-y-0.5">
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={clampForDisplay(view.stdout)} /> : clampForDisplay(view.stdout)}
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
</pre>
</div>
)}
@@ -535,7 +494,7 @@ function ToolEntry({ part }: ToolEntryProps) {
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
)}
>
{view.rendersAnsi ? <AnsiText text={clampForDisplay(view.stderr)} /> : clampForDisplay(view.stderr)}
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
</pre>
</div>
)}
@@ -545,10 +504,10 @@ function ToolEntry({ part }: ToolEntryProps) {
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{renderDetailAsCode ? (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={clampForDisplay(view.detail)} /> : clampForDisplay(view.detail)}
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
</pre>
) : (
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={clampForDisplay(view.detail)} />
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
)}
</div>
))}

View File

@@ -407,11 +407,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const totalCount = stages.length
const failed = Boolean(state.error)
// Count the running stage as half-done so the bar advances *during* a long
// stage instead of sitting frozen at the last completed step while its logs
// stream (e.g. "0 of 2" pinned at 0% for the whole first stage).
const progressUnits = completedCount + (!failed && currentStage ? 0.5 : 0)
const progressPct = totalCount > 0 ? Math.round((progressUnits / totalCount) * 100) : 0
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
const currentStartedAt = currentStage ? state.stages[currentStage]?.startedAt : null
const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : ''

View File

@@ -1,368 +0,0 @@
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, petProfile, setPetInfo } from '@/store/pet'
import { resetPetGallery } from '@/store/pet-gallery'
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
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
}
interface PetInfoMeta {
enabled: boolean
slug?: string
displayName?: string
scale?: number
spritesheetRevision?: string
}
function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean {
return (
info.enabled &&
Boolean(info.spritesheetBase64) &&
info.slug === meta.slug &&
info.displayName === meta.displayName &&
info.scale === meta.scale &&
info.spritesheetRevision === meta.spritesheetRevision
)
}
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 still refresh more slowly so generated
* pets rewritten on disk (or renamed/rebuilt by the hatch flow) repaint without
* restarting the app.
*
* 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
const PET_ACTIVE_REFRESH_MS = 15000
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. Poll quickly while inactive so an in-app
// `/pet <slug>` appears, then slowly while active so regenerated spritesheets
// and row-count metadata replace the cached base64 payload.
const active = info.enabled && Boolean(info.spritesheetBase64)
useEffect(() => {
if (gatewayState !== 'open') {
return
}
let cancelled = false
const pull = async () => {
try {
if (active) {
try {
const meta = await requestGateway<PetInfoMeta>('pet.info.meta', { profile: petProfile() })
if (cancelled || !meta) {
return
}
if (!meta.enabled) {
setPetInfo({ enabled: false })
return
}
if (samePetRevision($petInfo.get(), meta)) {
return
}
} catch {
// Older gateways may not have pet.info.meta yet; fall back to pet.info.
}
}
const next = await requestGateway<PetInfo>('pet.info', { profile: petProfile() })
if (!cancelled && next) {
const current = $petInfo.get()
if (
next.enabled &&
current.enabled &&
current.slug === next.slug &&
current.displayName === next.displayName &&
current.scale === next.scale &&
current.spritesheetRevision &&
current.spritesheetRevision === next.spritesheetRevision
) {
return
}
setPetInfo(next)
}
} catch {
// cosmetic feature — never surface gateway errors
}
}
void pull()
const timer = window.setInterval(() => void pull(), active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS)
window.addEventListener('focus', pull)
return () => {
cancelled = true
window.removeEventListener('focus', pull)
window.clearInterval(timer)
}
}, [gatewayState, active, requestGateway])
// Pets are per-profile. When the active profile changes, drop the previous
// profile's mascot + gallery cache so the poll above refetches the new
// profile's pet (its config + pets dir resolve per-profile on the backend).
const profileRef = useRef(normalizeProfileKey($activeGatewayProfile.get()))
useEffect(
() =>
$activeGatewayProfile.subscribe(next => {
const key = normalizeProfileKey(next)
if (key === profileRef.current) {
return
}
profileRef.current = key
setPetInfo({ enabled: false })
resetPetGallery()
}),
[]
)
// 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>
)
}

View File

@@ -1,142 +0,0 @@
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', 'balls 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>
)
}

View File

@@ -1,68 +0,0 @@
/**
* Egg-hatch visuals for the pet generation flow (Cmd-K → Pets → Generate).
*
* `PetEggHatch` is the incubation beat shown while `pet.hatch` runs: a wobbling
* egg that reads as "something is about to hatch" instead of a bare spinner. The
* reveal celebration is the canvas `PetStarShower`. Motion is disabled under
* `prefers-reduced-motion`.
*/
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
interface PetEggHatchProps {
subtitle?: string
onCancel?: () => void
cancelLabel?: string
}
/**
* Thin progress bar. Determinate when given done/total (hatch rows stream one by
* one, so a real percentage is meaningful); indeterminate otherwise (drafts
* return together, so a count would just snap 0→100).
*/
export function PetProgress({ done, total }: { done?: number; total?: number }) {
const determinate = typeof done === 'number' && typeof total === 'number' && total > 0
const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0
return (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={determinate ? pct : undefined}
className="pet-progress"
role="progressbar"
>
{determinate ? (
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
) : (
<div className="pet-progress__indeterminate" />
)}
</div>
)
}
export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) {
return (
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex flex-col items-center">
<PixelEggSprite mode="bounce" size={88} />
{/* The egg sprite has transparent canvas below the art, so pull the
shadow up ~a fifth of its size to sit at the egg's base. */}
<span className="pet-egg-shadow" style={{ marginTop: '-0.55rem' }} />
</div>
{subtitle && (
<p className="shimmer shimmer-color-primary whitespace-nowrap text-center text-[length:var(--conversation-caption-font-size)] leading-snug text-(--ui-text-tertiary)">
{subtitle}
</p>
)}
{onCancel && (
<Button onClick={onCancel} size="xs" variant="text">
{cancelLabel ?? 'Cancel'}
</Button>
)}
</div>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,236 +0,0 @@
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']
}
const ROW_TO_STATE: Record<string, PetState> = {
idle: 'idle',
wave: 'wave',
waving: 'wave',
jump: 'jump',
jumping: 'jump',
run: 'run',
running: 'run',
'running-right': 'run',
'running-left': 'run',
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
/**
* Force a specific animation state instead of reading the live `$petState`.
* Used by the generate-flow preview to showcase every row without driving (or
* being driven by) the real agent activity that moves the floating mascot.
*/
stateOverride?: PetState
/** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */
rowOverride?: string
}
/**
* 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, stateOverride, rowOverride }: PetSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<PetState>($petState.get())
const overrideRef = useRef<PetState | undefined>(stateOverride)
const rowOverrideRef = useRef<string | undefined>(rowOverride)
// Keep the override current without re-running the RAF setup effect.
useEffect(() => {
overrideRef.current = stateOverride
}, [stateOverride])
useEffect(() => {
rowOverrideRef.current = rowOverride
}, [rowOverride])
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 framesByRow = info.framesByRow
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
let activeRow = -1
let activeCount = -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 resolveRow = (rowName: string): { row: number; count: number } => {
const row = rows.indexOf(rowName)
const state = ROW_TO_STATE[rowName]
const count = Math.max(
1,
framesByRow?.[rowName] ?? framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames
)
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
}
const render = (now: number) => {
const forcedRow = rowOverrideRef.current
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
if (row !== activeRow || count !== activeCount) {
activeRow = row
activeCount = count
frame = 0
lastStep = now
drawnFrame = -1
}
// 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, framesByRow, 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)

View File

@@ -1,204 +0,0 @@
import { useEffect, useRef } from 'react'
/**
* Canvas hatch celebration layered over a freshly revealed pet: a one-shot
* sunburst of rotating god-rays, a fast radial star burst (confetti physics —
* velocity + decay + gravity + spin), and a light trickle of rising twinkle
* motes. Additive (`lighter`) so the sparkles bloom. No glow-halo flash.
*
* Sized to its container (absolute inset-0, pointer-events: none) and disabled
* under `prefers-reduced-motion`.
*/
const GOLD = '#ffd76a'
const BURST = 15
const VELOCITY = 500
const DECAY = 0.9
const GRAVITY = 90
const RAY_COUNT = 24
const GOLD_MIX = 0.6
const MOTE_MS = 333 // ~3 / sec
interface Star {
x: number
y: number
vx: number
vy: number
size: number
rot: number
vrot: number
phase: number
twinkle: number
life: number
ttl: number
color: string
rise: boolean
}
function readAccent(el: HTMLElement): string {
return getComputedStyle(el).getPropertyValue('--ui-accent').trim() || '#9aa0ff'
}
function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color: string): void {
ctx.rotate(rot)
ctx.fillStyle = color
for (const [rx, ry] of [
[size, size * 0.26],
[size * 0.26, size]
]) {
ctx.beginPath()
ctx.moveTo(0, -ry)
ctx.lineTo(rx, 0)
ctx.lineTo(0, ry)
ctx.lineTo(-rx, 0)
ctx.closePath()
ctx.fill()
}
const core = Math.max(1, Math.round(size * 0.4))
ctx.fillStyle = '#fff'
ctx.fillRect(-core / 2, -core / 2, core, core)
}
export function PetStarShower() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
const parent = canvas?.parentElement
if (!canvas || !ctx || !parent) {
return
}
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
return
}
const accent = readAccent(canvas)
const dpr = Math.min(window.devicePixelRatio || 1, 3)
let w = 0
let h = 0
let cx = 0
let cy = 0
const resize = () => {
const r = parent.getBoundingClientRect()
w = r.width
h = r.height
cx = w / 2
cy = h * 0.54
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(parent)
const pick = () => (Math.random() < GOLD_MIX ? GOLD : Math.random() < 0.5 ? accent : '#ffffff')
const stars: Star[] = []
for (let i = 0; i < BURST; i++) {
const a = Math.random() * Math.PI * 2
const sp = VELOCITY * (0.4 + Math.random() * 0.7)
stars.push({
x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
size: 3.5 + Math.random() * 5.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 8,
phase: 0, twinkle: 0, life: 0, ttl: 0.8 + Math.random() * 0.7, color: pick(), rise: false
})
}
const rays = { life: 0, ttl: 0.9, rot: Math.random() * 6.28 }
let raf = 0
let last = performance.now()
let acc = 0
let raysAlive = true
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
const ms = now - last
last = now
const dt = Math.min(0.05, ms / 1000)
const decay = Math.pow(DECAY, dt * 60)
acc += ms
if (acc >= MOTE_MS && stars.length < 40) {
acc = 0
stars.push({
x: cx + (Math.random() - 0.5) * w * 0.85, y: cy + Math.random() * h * 0.25,
vx: (Math.random() - 0.5) * 14, vy: -(14 + Math.random() * 26),
size: 2.5 + Math.random() * 3.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 2,
phase: Math.random() * 6.28, twinkle: 5 + Math.random() * 4, life: 0, ttl: 1.2 + Math.random(),
color: pick(), rise: true
})
}
ctx.clearRect(0, 0, w, h)
ctx.globalCompositeOperation = 'lighter'
// Sunburst god-rays — one-shot bloom + slow spin.
if (raysAlive) {
rays.life += dt
rays.rot += dt * 0.6
const t = rays.life / rays.ttl
if (t >= 1) {
raysAlive = false
} else {
const len = Math.max(w, h) * 0.62 * (1 - (1 - t) ** 2)
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(rays.rot)
for (let i = 0; i < RAY_COUNT; i++) {
ctx.rotate((Math.PI * 2) / RAY_COUNT)
const a = (1 - t) * 0.3 * (i % 2 ? 0.65 : 1)
const wd = len * 0.05
const g = ctx.createLinearGradient(0, 0, 0, -len)
g.addColorStop(0, `rgba(255,255,255,${a})`)
g.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = g
ctx.beginPath()
ctx.moveTo(-wd, 0)
ctx.lineTo(wd, 0)
ctx.lineTo(0, -len)
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
}
for (let i = stars.length - 1; i >= 0; i--) {
const s = stars[i]
s.life += dt
if (s.rise) {
s.vy += 7 * dt
s.phase += s.twinkle * dt
} else {
s.vx *= decay
s.vy = s.vy * decay + GRAVITY * dt
}
s.x += s.vx * dt
s.y += s.vy * dt
s.rot += s.vrot * dt
if (s.life >= s.ttl || s.y < -12) {
stars.splice(i, 1)
continue
}
const fade = s.rise
? Math.min(1, s.life * 5, (s.ttl - s.life) * 3) * (0.45 + 0.55 * Math.abs(Math.sin(s.phase)))
: Math.min(1, (s.ttl - s.life) * 3)
ctx.save()
ctx.globalAlpha = fade
ctx.translate(Math.round(s.x), Math.round(s.y))
sparkle(ctx, s.size, s.rot, s.color)
ctx.restore()
}
ctx.globalCompositeOperation = 'source-over'
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
}, [])
return <canvas className="pointer-events-none absolute inset-0 z-10 h-full w-full" ref={canvasRef} />
}

View File

@@ -1,79 +0,0 @@
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>
)
}

View File

@@ -1,234 +0,0 @@
import { type CSSProperties, useEffect, useRef } from 'react'
import eggSheetUrl from './pet-egg-sheet.png'
/**
* Animated pixel egg — the iamcrog "bouncing hatching egg" 12-frame sheet
* (32×32 cells, stacked vertically), drawn to a canvas and recolored to a warm
* white/creme shell.
*
* The sheet's shell is mid-gray, so a plain multiply only darkens it (still
* gray). Instead we remap each pixel's luminance through a creme ramp via a 256-
* entry LUT: near-black stays a warm dark outline, midtones become creme shadow,
* highlights go near-white. Done on a 32×32 offscreen then nearest-neighbor
* scaled up so it stays crisp.
*
* Frames 05 are the intact squash/stretch bounce; 611 are the crack/hatch.
* `mode="bounce"` loops 05 (never shows a crack); `mode="hatch"` plays 611
* once then calls onDone.
*/
const FRAME = 32
const TOTAL_FRAMES = 12
const BOUNCE_FRAMES = 6 // 0..5 — intact egg only; cracks start at frame 6
const HATCH_START = 6 // first crack frame
// Per-frame speed *while* a bounce is playing.
const BOUNCE_MS = 250
const HATCH_MS = 190
// Harvest-Moon idle: the egg rests on frame 0 for a long, randomized gap between
// bounces so it reads as "occasionally stirs", not "constantly animating".
const REST_MIN_MS = 2600
const REST_MAX_MS = 6200
// Creme ramp endpoints: warm dark outline → creme shadow → near-white highlight.
const OUTLINE: [number, number, number] = [78, 66, 58]
const SHADOW: [number, number, number] = [214, 198, 168]
const HIGHLIGHT: [number, number, number] = [253, 249, 238]
const OUTLINE_CUTOFF = 46
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
// Precompute the luminance→creme mapping once (shared across every egg). Below
// the cutoff it's the flat outline; above, a SHADOW→HIGHLIGHT ramp.
const CREME_LUT = (() => {
const lut = new Uint8ClampedArray(256 * 3)
for (let g = 0; g < 256; g++) {
const dark = g < OUTLINE_CUTOFF
const t = dark ? 0 : (g - OUTLINE_CUTOFF) / (255 - OUTLINE_CUTOFF)
const from = dark ? OUTLINE : SHADOW
const to = dark ? OUTLINE : HIGHLIGHT
lut.set([lerp(from[0], to[0], t), lerp(from[1], to[1], t), lerp(from[2], to[2], t)], g * 3)
}
return lut
})()
let _sheet: HTMLImageElement | null = null
let _sheetLoading: Promise<HTMLImageElement> | null = null
function loadSheet(): Promise<HTMLImageElement> {
if (_sheet?.complete) {
return Promise.resolve(_sheet)
}
if (!_sheetLoading) {
_sheetLoading = new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
_sheet = img
resolve(img)
}
img.onerror = reject
img.src = eggSheetUrl
})
}
return _sheetLoading
}
interface PixelEggSpriteProps {
mode: 'bounce' | 'hatch'
/** On-screen size (px, square). */
size: number
/**
* Slot position in a grid of eggs. Used to deterministically spread each egg's
* first bounce across the rest window so neighbours never stir together (random
* jitter alone can collide with only a handful of eggs).
*/
index?: number
className?: string
style?: CSSProperties
/** Fired once when a `hatch` run reaches the final frame. */
onDone?: () => void
}
export function PixelEggSprite({ mode, size, index = 0, className, style, onDone }: PixelEggSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const onDoneRef = useRef(onDone)
onDoneRef.current = onDone
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
const dpr = Math.min(window.devicePixelRatio || 1, 3)
const dim = Math.round(size * dpr)
canvas.width = dim
canvas.height = dim
const lastFrame = TOTAL_FRAMES - 1
// Mild per-egg speed jitter so bounces don't feel mechanical.
const frameMs = (mode === 'bounce' ? BOUNCE_MS : HATCH_MS) * (0.85 + Math.random() * 0.3)
const restMs = () => REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS)
// First bounce: a deterministic per-slot slice of the rest window (so two
// eggs never start together) plus a little random jitter on top.
const firstDelay = ((index % 4) + 1) * (REST_MIN_MS / 4) + Math.random() * REST_MIN_MS
// 32×32 offscreen we recolor per frame, then scale up nearest-neighbor.
const off = document.createElement('canvas')
off.width = FRAME
off.height = FRAME
const offCtx = off.getContext('2d', { willReadFrequently: true })
let sheet: HTMLImageElement | null = null
void loadSheet().then(img => {
sheet = img
})
const render = (frame: number) => {
if (!sheet || !offCtx) {
return
}
offCtx.clearRect(0, 0, FRAME, FRAME)
offCtx.imageSmoothingEnabled = false
offCtx.drawImage(sheet, 0, frame * FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME)
const img = offCtx.getImageData(0, 0, FRAME, FRAME)
const d = img.data
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] === 0) {
continue
}
const g = d[i] * 3
d[i] = CREME_LUT[g]
d[i + 1] = CREME_LUT[g + 1]
d[i + 2] = CREME_LUT[g + 2]
}
offCtx.putImageData(img, 0, 0)
ctx.clearRect(0, 0, dim, dim)
ctx.imageSmoothingEnabled = false
ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim)
}
let raf = 0
let step = 0
let finished = false
// bounce: `nextAt` is when the next thing happens — the next bounce frame, or
// the start of a new bounce after a rest. hatch: `lastHatch` time-gates frames.
let resting = mode === 'bounce'
let nextAt = 0
let lastHatch = 0
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
if (!sheet) {
return
}
if (mode === 'hatch') {
if (!lastHatch) {
lastHatch = now
render(HATCH_START)
return
}
if (now - lastHatch < frameMs) {
return
}
lastHatch = now
const frame = Math.min(HATCH_START + step, lastFrame)
render(frame)
if (frame >= lastFrame) {
if (!finished) {
finished = true
onDoneRef.current?.()
}
return // hold the cracked-open last frame
}
step += 1
return
}
// bounce: rest on frame 0, play 0..5, then rest again.
if (!nextAt) {
render(0)
nextAt = now + firstDelay // staggered first bounce, per slot
return
}
if (now < nextAt) {
return
}
if (resting) {
resting = false
step = 0
render(0)
nextAt = now + frameMs
return
}
step += 1
if (step >= BOUNCE_FRAMES) {
resting = true
render(0)
nextAt = now + restMs()
return
}
render(step)
nextAt = now + frameMs
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
}
}, [mode, size, index])
return (
<canvas
className={className}
ref={canvasRef}
style={{ width: size, height: size, imageRendering: 'pixelated', ...style }}
/>
)
}

View File

@@ -17,12 +17,7 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
)
}
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) {
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
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" />
@@ -34,7 +29,6 @@ function CommandInput({ className, right, ...props }: CommandInputProps) {
data-slot="command-input"
{...props}
/>
{right}
</div>
)
}

View File

@@ -35,98 +35,16 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
)
}
type DialogBannerTone = 'error' | 'warn' | 'info'
// Tinted, edge-to-edge bottom banner per tone. Error/warn keep their semantic
// destructive/primary tokens; info derives from the dialog's own bubble
// background so it reads as part of the themed dialog — lifted 30% toward white
// in light mode, deepened 20% toward black in dark mode.
const DIALOG_BANNER_TONES: Record<DialogBannerTone, string> = {
error: 'bg-destructive/12 text-destructive',
warn: 'bg-primary/12 text-primary',
info: 'bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_30%)] text-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_60%)] dark:bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_20%)] dark:text-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_60%)]'
}
function DialogContent({
className,
children,
showCloseButton = true,
fitContent = false,
banner,
bannerTone = 'error',
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
// Size the dialog to its content (capped at the viewport) instead of the
// default fixed `max-w-lg`. For content that has no intrinsic width (grids,
// full-width inputs) pair it with a `min-w-*` in `className`.
fitContent?: boolean
// A dialog-level notice rendered as a banner flush to the bottom edge (tinted,
// inherited bottom radius) so it reads as part of the dialog, not a floating
// alert. Falsy → no banner. Tone picks the colour.
banner?: React.ReactNode
bannerTone?: DialogBannerTone
}) {
const { t } = useI18n()
const widthClass = fitContent ? 'w-auto max-w-[92vw]' : 'w-full max-w-lg'
const closeButton = showCloseButton ? (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
) : null
// With a banner, the border can't live on the scroll/clip box (it would draw a
// line around the banner too). The white body keeps its own bottom radius and
// sits over the tinted footer; the outer shell only clips the banner to the
// dialog's rounded bottom edge.
if (banner) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className,
// Callers often pass `gap-*` for the no-banner grid layout — suppress
// it here so the banner can tuck under the body's rounded bottom edge.
'gap-0'
)}
data-slot="dialog-content"
{...props}
>
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
</div>
<div
className={cn(
// Overlap by one corner radius so the white bottom lobes read clearly
// over the tint instead of meeting it on a straight seam.
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
DIALOG_BANNER_TONES[bannerTone]
)}
data-slot="dialog-banner"
role={bannerTone === 'error' ? 'alert' : 'status'}
>
{banner}
</div>
{closeButton}
</DialogPrimitive.Content>
</DialogPortal>
)
}
return (
<DialogPortal>
<DialogOverlay />
@@ -135,15 +53,26 @@ function DialogContent({
// Cap height at 85vh and let long content scroll inside the dialog
// instead of overflowing off-screen (long cron titles, tool detail
// dumps, etc.). Individual dialogs can still override via className.
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dialog-content"
{...props}
>
{children}
{closeButton}
{showCloseButton && (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)

View File

@@ -1,62 +0,0 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface GenerateButtonProps extends Omit<React.ComponentProps<typeof Button>, 'children' | 'onClick'> {
/** True while a generation is in flight. */
generating: boolean
/** Start a generation. */
onGenerate: () => void
/** Cancel an in-flight generation. When omitted, the button just spins while
* generating (for one-shots that can't be cancelled). */
onCancel?: () => void
/** Tooltip + aria label at rest (and while generating if no `generatingLabel`). */
label: string
/** Tooltip while generating (e.g. "Stop" with cancel, "Generating…" without). */
generatingLabel?: string
iconSize?: number | string
}
/** The sparkle "generate with AI" affordance — icon + tooltip, shared by the
* commit-message box and the new-project idea field so they stay one pattern.
* Sparkle → click generates; with `onCancel`, a Stop square appears mid-run;
* without it, the sparkle spins until the one-shot resolves. */
export function GenerateButton({
generating,
onGenerate,
onCancel,
label,
generatingLabel,
disabled,
iconSize = 12,
className,
...rest
}: GenerateButtonProps) {
const tip = generating ? (generatingLabel ?? label) : label
const cancellable = generating && !!onCancel
return (
<Tip label={tip}>
<Button
aria-label={tip}
className={cn('text-muted-foreground/80 hover:text-foreground', className)}
disabled={generating ? !onCancel : disabled}
onClick={cancellable ? onCancel : onGenerate}
size="icon-xs"
type="button"
variant="ghost"
{...rest}
>
{cancellable ? (
<Square className="fill-current" size={11} />
) : (
<Codicon name="sparkle" size={iconSize} spinning={generating} />
)}
</Button>
</Tip>
)
}

View File

@@ -1,10 +1,3 @@
import type {
PetOverlayBounds,
PetOverlayControl,
PetOverlayOpenRequest,
PetOverlayStatePayload
} from './store/pet-overlay'
export {}
declare global {
@@ -33,20 +26,6 @@ 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>

View File

@@ -1,49 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkHermesUpdate,
getActionStatus,
getStatus,
restartGateway,
setApiRequestProfile,
updateHermes
} from './hermes'
// Contract: every backend-targeted action helper must carry the active gateway
// profile, so a multi-profile / global-remote user's restart, status poll, and
// update hit the backend they're actually on — not the primary/default. The
// System-panel "restart does nothing" bug was these helpers dropping it.
describe('backend action helpers are profile-scoped', () => {
const api = vi.fn(async (_req: { path: string; profile?: string }) => ({}) as never)
beforeEach(() => {
;(window as { hermesDesktop?: unknown }).hermesDesktop = { api }
api.mockClear()
})
afterEach(() => {
setApiRequestProfile(null)
delete (window as { hermesDesktop?: unknown }).hermesDesktop
})
const lastProfile = () => api.mock.calls.at(-1)?.[0].profile
it('omits profile when none is active (single-profile users unaffected)', () => {
void getStatus()
expect(lastProfile()).toBeUndefined()
})
it('forwards the active profile to every backend action', () => {
setApiRequestProfile('coder')
void getStatus()
void restartGateway()
void updateHermes()
void checkHermesUpdate()
void getActionStatus('gateway-restart')
for (const call of api.mock.calls) {
expect(call[0].profile).toBe('coder')
}
})
})

View File

@@ -274,7 +274,6 @@ export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
export function getStatus(): Promise<StatusResponse> {
return window.hermesDesktop.api<StatusResponse>({
...profileScoped(),
path: '/api/status'
})
}
@@ -757,7 +756,6 @@ export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelA
export function restartGateway(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/gateway/restart',
method: 'POST'
})
@@ -765,7 +763,6 @@ export function restartGateway(): Promise<ActionResponse> {
export function updateHermes(): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...profileScoped(),
path: '/api/hermes/update',
method: 'POST'
})
@@ -776,14 +773,12 @@ export function updateHermes(): Promise<ActionResponse> {
* distinct from the Electron client clone's git state. */
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
...profileScoped(),
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
})
}
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
...profileScoped(),
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
})
}

View File

@@ -57,7 +57,6 @@ export const en: Translations = {
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
backendStopped: 'Backend stopped',
desktopBootFailed: 'Desktop boot failed',
gatewayConnectionLost: 'Lost connection to the gateway',
gatewaySignInRequired: 'Gateway sign-in required',
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
},
@@ -212,7 +211,6 @@ export const en: Translations = {
'session.togglePin': 'Pin / unpin current session',
'composer.focus': 'Focus composer',
'composer.modelPicker': 'Open model picker',
'composer.voice': 'Start / stop voice conversation',
'view.toggleSidebar': 'Toggle sessions sidebar',
'view.toggleRightSidebar': 'Toggle file browser',
'view.showFiles': 'Show file browser',
@@ -374,44 +372,7 @@ export const en: Translations = {
installError: 'Could not install that theme.',
installed: name => `Installed “${name}”.`,
removeTheme: 'Remove theme',
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',
generatedTag: 'Generated',
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
uninstall: name => `Uninstall ${name}`,
delete: name => `Delete ${name}`,
deleteTitle: name => `Delete ${name}?`,
deleteBody: "This permanently deletes the pet — it can't be reinstalled.",
deleteConfirm: 'Delete',
rename: name => `Rename ${name}`,
renameTitle: 'Rename pet',
renamePlaceholder: 'Name your pet',
renameSave: 'Save',
exportPet: name => `Export ${name}`,
adoptFailed: slug => `Could not adopt ${slug}`,
uninstallFailed: slug => `Could not uninstall ${slug}`,
renameFailed: slug => `Could not rename ${slug}`,
exportFailed: slug => `Could not export ${slug}`,
noneAvailable: 'No pets available to turn on right now.',
turnOnFailed: 'Could not turn the pet on.',
turnOffFailed: 'Could not turn the pet off.'
}
importedBadge: 'Imported'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@@ -762,48 +723,8 @@ 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',
generatedTag: 'Generated',
adoptFailed: 'Could not adopt that pet.',
toggleFailed: 'Could not toggle the pet.',
noneAvailable: 'No pets available — pick one below to install.'
},
generatePet: {
title: 'Generate a pet',
placeholder: 'Describe a pet to generate…',
promptHint: 'Type a description, then press Enter to draft four looks.',
readyHint: 'Press Enter to draft four looks from your description.',
generate: 'Generate',
generating: 'Generating…',
retry: 'Retry',
hatch: 'Hatch',
spawning: 'Spawning…',
hatching: 'Hatching your pet…',
hatchingSub: 'Bringing it to life…',
hatched: 'It hatched!',
hatchRow: (_state, done, total) => `Sketching frame ${done} of ${total}`,
hatchComposing: 'Piecing it together…',
hatchSaving: 'Almost there…',
namePlaceholder: 'Name your pet',
staleBackend: 'Update Hermes to generate pets.',
backgroundHint: 'You can close this — Hermes will notify you when its done.',
genericError: 'Generation failed — try again or pick a suggestion.',
referenceImageTooLarge: 'Reference image is too large. Use one under 16 MB.',
referenceImageInvalid: 'Could not read that reference image. Try a PNG, JPG, WebP, or GIF.',
adopt: 'Adopt',
startOver: 'Start over'
},
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',
@@ -1845,8 +1766,7 @@ export const en: Translations = {
restoreCheckpoint: 'Restore checkpoint',
restoreFromHere: 'Restore checkpoint — rerun from this prompt',
restoreTitle: 'Restore to this checkpoint?',
restoreBody:
'Everything after this prompt is removed from the conversation, and the prompt runs again from here.',
restoreBody: 'Everything after this prompt is removed from the conversation, and the prompt runs again from here.',
restoreConfirm: 'Restore & rerun',
restoreNext: 'Restore next checkpoint',
goForward: 'Go forward',
@@ -1902,67 +1822,7 @@ export const en: Translations = {
statusRunning: 'Running',
statusError: 'Error',
statusRecovered: 'Recovered',
statusDone: 'Done',
actions: {
read: 'Read',
reading: 'Reading',
opened: 'Opened',
opening: 'Opening',
searched: 'Searched',
searching: 'Searching',
ran: 'Ran',
running: 'Running',
ranCode: 'Ran code',
runningCode: 'Scripting'
},
prefixes: {
browser: 'Browser',
web: 'Web'
},
titleTemplates: {
actionCommand: (action, command) => `${action} · ${command}`,
actionQuoted: (action, value) => `${action}${value}`,
actionTarget: (action, target) => `${action} ${target}`,
prefixedDone: (prefix, action) => `${prefix} ${action}`,
runningPrefixedTool: (prefix, action) => `Running ${prefix.toLowerCase()} ${action.toLowerCase()}`,
runningTool: action => `Running ${action.toLowerCase()}`
},
titles: {
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', pendingAction: 'Clicking' },
browser_fill: { done: 'Filled form field', pending: 'Filling form field', pendingAction: 'Filling' },
browser_navigate: { done: 'Opened page', pending: 'Opening page', pendingAction: 'Opening' },
browser_snapshot: {
done: 'Captured page snapshot',
pending: 'Capturing page snapshot',
pendingAction: 'Capturing'
},
browser_take_screenshot: {
done: 'Captured screenshot',
pending: 'Capturing screenshot',
pendingAction: 'Capturing'
},
browser_type: { done: 'Typed on page', pending: 'Typing on page', pendingAction: 'Typing' },
clarify: { done: 'Asked a question', pending: 'Asking a question', pendingAction: 'Asking' },
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', pendingAction: 'Scheduling' },
edit_file: { done: 'Edited file', pending: 'Editing file', pendingAction: 'Editing' },
execute_code: { done: 'Ran code', pending: 'Scripting', pendingAction: 'Scripting' },
image_generate: { done: 'Generated image', pending: 'Generating image', pendingAction: 'Generating' },
list_files: { done: 'Listed files', pending: 'Listing files', pendingAction: 'Listing' },
patch: { done: 'Patched file', pending: 'Patching file', pendingAction: 'Patching' },
read_file: { done: 'Read file', pending: 'Reading file', pendingAction: 'Reading' },
search_files: { done: 'Searched files', pending: 'Searching files', pendingAction: 'Searching' },
session_search_recall: {
done: 'Searched session history',
pending: 'Searching session history',
pendingAction: 'Searching'
},
terminal: { done: 'Ran command', pending: 'Running command', pendingAction: 'Running' },
todo: { done: 'Updated todos', pending: 'Updating todos', pendingAction: 'Updating' },
vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', pendingAction: 'Analyzing' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage', pendingAction: 'Reading' },
web_search: { done: 'Searched web', pending: 'Searching web', pendingAction: 'Searching' },
write_file: { done: 'Edited file', pending: 'Editing file', pendingAction: 'Editing' }
}
statusDone: 'Done'
}
},
@@ -2005,8 +1865,7 @@ export const en: Translations = {
editFailed: 'Edit failed',
resumeFailed: 'Resume failed',
resumeStrandedTitle: "Couldn't load this session",
resumeStrandedBody:
'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
resumeRetry: 'Retry',
nothingToBranch: 'Nothing to branch',
branchNeedsChat: 'Start or resume a chat before branching.',

View File

@@ -17,4 +17,4 @@ export {
normalizeLocale
} from './languages'
export { setRuntimeI18nLocale, translateNow } from './runtime'
export type { Locale, ToolTitleKey, Translations } from './types'
export type { Locale, Translations } from './types'

View File

@@ -57,7 +57,6 @@ export const ja = defineLocale({
backgroundExitedDuringStartup: '起動中に Hermes バックグラウンドプロセスが終了しました。',
backendStopped: 'バックエンドが停止しました',
desktopBootFailed: 'デスクトップの起動に失敗しました',
gatewayConnectionLost: 'ゲートウェイへの接続が切断されました',
gatewaySignInRequired: 'ゲートウェイへのサインインが必要です',
ipcBridgeUnavailable: 'デスクトップ IPC ブリッジが利用できません。'
},
@@ -201,7 +200,8 @@ export const ja = defineLocale({
},
notifications: {
title: '通知',
intro: 'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
intro:
'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
enableAll: '通知を有効にする',
enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。',
focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。',
@@ -287,44 +287,7 @@ export const ja = defineLocale({
installError: 'そのテーマをインストールできませんでした。',
installed: name => `${name}」をインストールしました。`,
removeTheme: 'テーマを削除',
importedBadge: 'インポート済み',
pet: {
title: 'ペット',
intro:
'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。',
restartHint:
'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。',
scaleTitle: 'サイズ',
scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。',
on: 'オン',
off: 'オフ',
chooseTitle: 'ペットを選ぶ',
chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。',
searchPlaceholder: 'ペットを検索…',
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
noMatch: query => `${query}」に一致するペットがありません。`,
installedTag: 'インストール済み',
generatedTag: '生成',
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
count: n => `${n} 件のペット。`,
uninstall: name => `${name} をアンインストール`,
delete: name => `${name} を削除`,
deleteTitle: name => `${name} を削除しますか?`,
deleteBody: 'ペットを完全に削除します。再インストールはできません。',
deleteConfirm: '削除',
rename: name => `${name} の名前を変更`,
renameTitle: 'ペットの名前を変更',
renamePlaceholder: 'ペットに名前を付ける',
renameSave: '保存',
exportPet: name => `${name} をエクスポート`,
adoptFailed: slug => `${slug} を採用できませんでした`,
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
renameFailed: slug => `${slug} の名前を変更できませんでした`,
exportFailed: slug => `${slug} をエクスポートできませんでした`,
noneAvailable: 'オンにできるペットがありません。',
turnOnFailed: 'ペットをオンにできませんでした。',
turnOffFailed: 'ペットをオフにできませんでした。'
}
importedBadge: 'インポート済み'
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@@ -880,48 +843,8 @@ export const ja = defineLocale({
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
changeTheme: 'テーマを変更',
changeTheme: 'テーマを変更...',
changeColorMode: 'カラーモードを変更...',
pets: {
title: 'ペット',
placeholder: 'ペットを検索…',
loading: 'petdex ギャラリーを読み込み中…',
error: 'petdex ギャラリーに接続できません。',
staleBackend: 'ペット機能を使うには Hermes を再起動してください。',
empty: '一致するペットがありません。',
turnOff: 'オフ',
turnOn: 'オン',
installed: 'インストール済み',
generatedTag: '生成',
adoptFailed: 'ペットを採用できませんでした。',
toggleFailed: 'ペットを切り替えできませんでした。',
noneAvailable: '利用可能なペットがありません。'
},
generatePet: {
title: 'ペットを生成',
placeholder: '生成するペットを説明…',
promptHint: '説明を入力して Enter を押すと、4 つの見た目を生成します。',
readyHint: 'Enter を押すと、説明から 4 つの見た目を生成します。',
generate: '生成',
generating: '生成中…',
retry: '再試行',
hatch: '孵化',
spawning: 'スポーン中…',
hatching: 'ペットを孵化しています…',
hatchingSub: '命を吹き込んでいます…',
hatched: '孵化しました!',
hatchRow: (_state, done, total) => `フレームを描画中… ${done}/${total}`,
hatchComposing: 'まとめています…',
hatchSaving: 'もうすぐです…',
namePlaceholder: 'ペットに名前を付ける',
staleBackend: 'ペットを生成するには Hermes を更新してください。',
backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。',
genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。',
referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。',
referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。',
adopt: '迎え入れる',
startOver: 'やり直す'
},
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',
@@ -1497,8 +1420,7 @@ export const ja = defineLocale({
queueSend: '送信',
queueDelete: '削除',
queueStuckTitle: 'キュー内のメッセージを送信できません',
queueStuckBody:
'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
queueStuckBody: 'キューに入れたターンの送信が繰り返し失敗しました。まだキューに残っています。もう一度送信してください。',
previewUnavailable: 'プレビューは利用できません',
previewLabel: label => `${label} のプレビュー`,
couldNotPreview: label => `${label} をプレビューできませんでした`,
@@ -1597,8 +1519,7 @@ export const ja = defineLocale({
copy: 'コピー',
copied: 'コピーしました',
done: '完了',
applyingBody:
'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に自動的に Hermes を再度開きます。更新中はご自分で Hermes を開き直さないでください。',
applyingBody: 'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に自動的に Hermes を再度開きます。更新中はご自分で Hermes を開き直さないでください。',
applyingBodyBackend: 'リモートバックエンドが更新を適用して再起動します。復帰すると Hermes が自動的に再接続します。',
applyingClose: 'このウィンドウは更新中に閉じ、その後 Hermes が自動的に再度開きます。',
errorTitle: '更新が完了しませんでした',
@@ -2030,83 +1951,7 @@ export const ja = defineLocale({
statusRunning: '実行中',
statusError: 'エラー',
statusRecovered: '回復しました',
statusDone: '完了',
actions: {
read: '読み取り完了',
reading: '読み取り中',
opened: 'オープン済み',
opening: 'オープン中',
searched: '検索完了',
searching: '検索中',
ran: '実行完了',
running: '実行中',
ranCode: 'コード実行完了',
runningCode: 'スクリプト作成中'
},
prefixes: {
browser: 'ブラウザー',
web: 'Web'
},
titleTemplates: {
actionCommand: (action, command) => `${action} · ${command}`,
actionQuoted: (action, value) => `${value}」を${action}`,
actionTarget: (action, target) => `${target}${action}`,
prefixedDone: (prefix, action) => `${prefix} ${action}`,
runningPrefixedTool: (prefix, action) => `${prefix} ${action}を実行中`,
runningTool: action => `${action}を実行中`
},
titles: {
browser_click: {
done: 'ページ要素をクリックしました',
pending: 'ページ要素をクリック中',
pendingAction: 'クリック中'
},
browser_fill: { done: 'フォーム欄に入力しました', pending: 'フォーム欄に入力中', pendingAction: '入力中' },
browser_navigate: { done: 'ページを開きました', pending: 'ページをオープン中', pendingAction: 'オープン中' },
browser_snapshot: {
done: 'ページスナップショットを取得しました',
pending: 'ページスナップショットを取得中',
pendingAction: '取得中'
},
browser_take_screenshot: {
done: 'スクリーンショットを取得しました',
pending: 'スクリーンショットを取得中',
pendingAction: '取得中'
},
browser_type: { done: 'ページに入力しました', pending: 'ページに入力中', pendingAction: '入力中' },
clarify: { done: '質問しました', pending: '質問中', pendingAction: '質問中' },
cronjob: { done: 'Cron ジョブ', pending: 'Cron ジョブをスケジュール中', pendingAction: 'スケジュール中' },
edit_file: { done: 'ファイルを編集しました', pending: 'ファイルを編集中', pendingAction: '編集中' },
execute_code: { done: 'コードを実行しました', pending: 'スクリプト作成中', pendingAction: 'スクリプト作成中' },
image_generate: { done: '画像を生成しました', pending: '画像を生成中', pendingAction: '生成中' },
list_files: {
done: 'ファイルを一覧表示しました',
pending: 'ファイルを一覧表示中',
pendingAction: '一覧表示中'
},
patch: {
done: 'ファイルにパッチを適用しました',
pending: 'ファイルにパッチ適用中',
pendingAction: 'パッチ適用中'
},
read_file: { done: 'ファイルを読み取りました', pending: 'ファイルを読み取り中', pendingAction: '読み取り中' },
search_files: { done: 'ファイルを検索しました', pending: 'ファイルを検索中', pendingAction: '検索中' },
session_search_recall: {
done: 'セッション履歴を検索しました',
pending: 'セッション履歴を検索中',
pendingAction: '検索中'
},
terminal: { done: 'コマンドを実行しました', pending: 'コマンドを実行中', pendingAction: '実行中' },
todo: { done: 'Todo を更新しました', pending: 'Todo を更新中', pendingAction: '更新中' },
vision_analyze: { done: '画像を分析しました', pending: '画像を分析中', pendingAction: '分析中' },
web_extract: {
done: 'Web ページを読み取りました',
pending: 'Web ページを読み取り中',
pendingAction: '読み取り中'
},
web_search: { done: 'Web を検索しました', pending: 'Web を検索中', pendingAction: '検索中' },
write_file: { done: 'ファイルを編集しました', pending: 'ファイルを編集中', pendingAction: '編集中' }
}
statusDone: '完了'
}
},
@@ -2150,8 +1995,7 @@ export const ja = defineLocale({
editFailed: '編集に失敗しました',
resumeFailed: '再開に失敗しました',
resumeStrandedTitle: 'このセッションを読み込めませんでした',
resumeStrandedBody:
'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
resumeRetry: '再試行',
nothingToBranch: 'ブランチするものがありません',
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',

View File

@@ -7,36 +7,6 @@
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja'
export type ToolTitleKey =
| 'browser_click'
| 'browser_fill'
| 'browser_navigate'
| 'browser_snapshot'
| 'browser_take_screenshot'
| 'browser_type'
| 'clarify'
| 'cronjob'
| 'edit_file'
| 'execute_code'
| 'image_generate'
| 'list_files'
| 'patch'
| 'read_file'
| 'search_files'
| 'session_search_recall'
| 'terminal'
| 'todo'
| 'vision_analyze'
| 'web_extract'
| 'web_search'
| 'write_file'
interface ToolTitleCopy {
done: string
pending: string
pendingAction: string
}
interface ModeOptionCopy {
label: string
description: string
@@ -102,7 +72,6 @@ export interface Translations {
backgroundExitedDuringStartup: string
backendStopped: string
desktopBootFailed: string
gatewayConnectionLost: string
gatewaySignInRequired: string
ipcBridgeUnavailable: string
}
@@ -301,41 +270,6 @@ 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
generatedTag: string
countCapped: (cap: number, total: number) => string
count: (n: number) => string
uninstall: (name: string) => string
delete: (name: string) => string
deleteTitle: (name: string) => string
deleteBody: string
deleteConfirm: string
rename: (name: string) => string
renameTitle: string
renamePlaceholder: string
renameSave: string
exportPet: (name: string) => string
adoptFailed: (slug: string) => string
uninstallFailed: (slug: string) => string
renameFailed: (slug: string) => string
exportFailed: (slug: string) => string
noneAvailable: string
turnOnFailed: string
turnOffFailed: string
}
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
@@ -668,46 +602,6 @@ 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
generatedTag: string
adoptFailed: string
toggleFailed: string
noneAvailable: string
}
generatePet: {
title: string
placeholder: string
promptHint: string
readyHint: string
generate: string
generating: string
retry: string
hatch: string
spawning: string
hatching: string
hatchingSub: string
hatched: string
hatchRow: (state: string, done: number, total: number) => string
hatchComposing: string
hatchSaving: string
namePlaceholder: string
staleBackend: string
backgroundHint: string
genericError: string
referenceImageTooLarge: string
referenceImageInvalid: string
adopt: string
startOver: string
}
installTheme: {
title: string
placeholder: string
@@ -1563,31 +1457,6 @@ export interface Translations {
statusError: string
statusRecovered: string
statusDone: string
actions: {
read: string
reading: string
opened: string
opening: string
searched: string
searching: string
ran: string
running: string
ranCode: string
runningCode: string
}
prefixes: {
browser: string
web: string
}
titleTemplates: {
actionCommand: (action: string, command: string) => string
actionQuoted: (action: string, value: string) => string
actionTarget: (action: string, target: string) => string
prefixedDone: (prefix: string, action: string) => string
runningPrefixedTool: (prefix: string, action: string) => string
runningTool: (action: string) => string
}
titles: Record<ToolTitleKey, ToolTitleCopy>
}
}

View File

@@ -57,7 +57,6 @@ export const zhHant = defineLocale({
backgroundExitedDuringStartup: 'Hermes 背景程序在啟動期間結束。',
backendStopped: '後端已停止',
desktopBootFailed: '桌面啟動失敗',
gatewayConnectionLost: '與閘道的連線已中斷',
gatewaySignInRequired: '需要閘道登入',
ipcBridgeUnavailable: '桌面 IPC 橋接器不可用。'
},
@@ -277,43 +276,7 @@ export const zhHant = defineLocale({
installError: '無法安裝該主題。',
installed: name => `已安裝「${name}」。`,
removeTheme: '移除主題',
importedBadge: '已匯入',
pet: {
title: '寵物',
intro:
'領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes然後回到此處。',
scaleTitle: '大小',
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
on: '開啟',
off: '關閉',
chooseTitle: '選擇寵物',
chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。',
searchPlaceholder: '搜尋寵物…',
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
noMatch: query => `沒有符合「${query}」的寵物。`,
installedTag: '已安裝',
generatedTag: '生成',
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
count: n => `${n} 個寵物。`,
uninstall: name => `解除安裝 ${name}`,
delete: name => `刪除 ${name}`,
deleteTitle: name => `刪除 ${name}`,
deleteBody: '此操作會永久刪除寵物,且無法重新安裝。',
deleteConfirm: '刪除',
rename: name => `重新命名 ${name}`,
renameTitle: '重新命名寵物',
renamePlaceholder: '為寵物取個名字',
renameSave: '儲存',
exportPet: name => `匯出 ${name}`,
adoptFailed: slug => `無法領養 ${slug}`,
uninstallFailed: slug => `無法解除安裝 ${slug}`,
renameFailed: slug => `無法重新命名 ${slug}`,
exportFailed: slug => `無法匯出 ${slug}`,
noneAvailable: '目前沒有可開啟的寵物。',
turnOnFailed: '無法開啟寵物。',
turnOffFailed: '無法關閉寵物。'
}
importedBadge: '已匯入'
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@@ -852,48 +815,8 @@ export const zhHant = defineLocale({
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
changeTheme: '變更主題',
changeTheme: '變更主題...',
changeColorMode: '變更色彩模式...',
pets: {
title: '寵物',
placeholder: '搜尋寵物…',
loading: '正在載入 petdex 畫廊…',
error: '無法連線至 petdex 畫廊。',
staleBackend: '請重新啟動 Hermes 以使用寵物功能。',
empty: '沒有符合的寵物。',
turnOff: '關閉',
turnOn: '開啟',
installed: '已安裝',
generatedTag: '生成',
adoptFailed: '無法領養該寵物。',
toggleFailed: '無法切換寵物顯示。',
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
},
generatePet: {
title: '生成寵物',
placeholder: '描述要生成的寵物……',
promptHint: '輸入描述,然後按 Enter 生成四種造型。',
readyHint: '按 Enter 依描述生成四種造型。',
generate: '生成',
generating: '生成中……',
retry: '重試',
hatch: '孵化',
spawning: '召喚中……',
hatching: '正在孵化你的寵物……',
hatchingSub: '正在注入生命……',
hatched: '孵化成功!',
hatchRow: (_state, done, total) => `正在繪製畫面…… ${done}/${total}`,
hatchComposing: '正在拼合……',
hatchSaving: '快好了……',
namePlaceholder: '為寵物命名',
staleBackend: '請更新 Hermes 以生成寵物。',
backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。',
genericError: '生成失敗——請重試或選一個建議。',
referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。',
referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。',
adopt: '領養',
startOver: '重新開始'
},
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',
@@ -1547,8 +1470,7 @@ export const zhHant = defineLocale({
copy: '複製',
copied: '已複製',
done: '完成',
applyingBody:
'Hermes 更新程式會在自己的視窗中接管,並在完成後自動重新開啟 Hermes。更新期間請勿自行重新開啟 Hermes。',
applyingBody: 'Hermes 更新程式會在自己的視窗中接管,並在完成後自動重新開啟 Hermes。更新期間請勿自行重新開啟 Hermes。',
applyingBodyBackend: '遠端後端正在套用更新並將重新啟動。恢復後 Hermes 會自動重新連線。',
applyingClose: '此視窗會在更新期間關閉,隨後 Hermes 會自動重新開啟。',
errorTitle: '更新未完成',
@@ -1970,59 +1892,7 @@ export const zhHant = defineLocale({
statusRunning: '執行中',
statusError: '錯誤',
statusRecovered: '已復原',
statusDone: '完成',
actions: {
read: '已讀取',
reading: '正在讀取',
opened: '已開啟',
opening: '正在開啟',
searched: '已搜尋',
searching: '正在搜尋',
ran: '已執行',
running: '正在執行',
ranCode: '已執行程式碼',
runningCode: '正在撰寫腳本'
},
prefixes: {
browser: '瀏覽器',
web: '網頁'
},
titleTemplates: {
actionCommand: (action, command) => `${action} · ${command}`,
actionQuoted: (action, value) => `${action}${value}`,
actionTarget: (action, target) => `${action} ${target}`,
prefixedDone: (prefix, action) => `${prefix}${action}`,
runningPrefixedTool: (prefix, action) => `正在執行${prefix}${action}`,
runningTool: action => `正在執行 ${action}`
},
titles: {
browser_click: { done: '已點擊頁面元素', pending: '正在點擊頁面元素', pendingAction: '正在點擊' },
browser_fill: { done: '已填寫表單欄位', pending: '正在填寫表單欄位', pendingAction: '正在填寫' },
browser_navigate: { done: '已開啟頁面', pending: '正在開啟頁面', pendingAction: '正在開啟' },
browser_snapshot: { done: '已擷取頁面快照', pending: '正在擷取頁面快照', pendingAction: '正在擷取' },
browser_take_screenshot: { done: '已擷取截圖', pending: '正在擷取截圖', pendingAction: '正在擷取' },
browser_type: { done: '已在頁面輸入', pending: '正在頁面輸入', pendingAction: '正在輸入' },
clarify: { done: '已提問', pending: '正在提問', pendingAction: '正在提問' },
cronjob: { done: 'Cron 工作', pending: '正在安排 Cron 工作', pendingAction: '正在安排' },
edit_file: { done: '已編輯檔案', pending: '正在編輯檔案', pendingAction: '正在編輯' },
execute_code: { done: '已執行程式碼', pending: '正在撰寫腳本', pendingAction: '正在撰寫腳本' },
image_generate: { done: '已生成圖片', pending: '正在生成圖片', pendingAction: '正在生成' },
list_files: { done: '已列出檔案', pending: '正在列出檔案', pendingAction: '正在列出' },
patch: { done: '已修補檔案', pending: '正在修補檔案', pendingAction: '正在修補' },
read_file: { done: '已讀取檔案', pending: '正在讀取檔案', pendingAction: '正在讀取' },
search_files: { done: '已搜尋檔案', pending: '正在搜尋檔案', pendingAction: '正在搜尋' },
session_search_recall: {
done: '已搜尋工作階段歷史',
pending: '正在搜尋工作階段歷史',
pendingAction: '正在搜尋'
},
terminal: { done: '已執行指令', pending: '正在執行指令', pendingAction: '正在執行' },
todo: { done: '已更新待辦', pending: '正在更新待辦', pendingAction: '正在更新' },
vision_analyze: { done: '已分析圖片', pending: '正在分析圖片', pendingAction: '正在分析' },
web_extract: { done: '已讀取網頁', pending: '正在讀取網頁', pendingAction: '正在讀取' },
web_search: { done: '已搜尋網頁', pending: '正在搜尋網頁', pendingAction: '正在搜尋' },
write_file: { done: '已編輯檔案', pending: '正在編輯檔案', pendingAction: '正在編輯' }
}
statusDone: '完成'
}
},

View File

@@ -57,7 +57,6 @@ export const zh: Translations = {
backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。',
backendStopped: '后端已停止',
desktopBootFailed: '桌面启动失败',
gatewayConnectionLost: '与网关的连接已断开',
gatewaySignInRequired: '需要登录网关',
ipcBridgeUnavailable: '桌面 IPC 桥不可用。'
},
@@ -207,7 +206,6 @@ export const zh: Translations = {
'session.togglePin': '固定/取消固定当前会话',
'composer.focus': '聚焦输入框',
'composer.modelPicker': '打开模型选择器',
'composer.voice': '开始 / 停止语音对话',
'view.toggleSidebar': '切换会话侧边栏',
'view.toggleRightSidebar': '切换文件浏览器',
'view.showFiles': '显示文件浏览器',
@@ -366,43 +364,7 @@ export const zh: Translations = {
installError: '无法安装该主题。',
installed: name => `已安装「${name}」。`,
removeTheme: '移除主题',
importedBadge: '已导入',
pet: {
title: '宠物',
intro:
'领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes然后回到此处。',
scaleTitle: '大小',
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
on: '开启',
off: '关闭',
chooseTitle: '选择宠物',
chooseDesc: '选择后会自动安装(如需)并设为当前宠物。',
searchPlaceholder: '搜索宠物…',
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
noMatch: query => `没有匹配「${query}」的宠物。`,
installedTag: '已安装',
generatedTag: '生成',
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
count: n => `${n} 个宠物。`,
uninstall: name => `卸载 ${name}`,
delete: name => `删除 ${name}`,
deleteTitle: name => `删除 ${name}`,
deleteBody: '此操作会永久删除宠物,且无法重新安装。',
deleteConfirm: '删除',
rename: name => `重命名 ${name}`,
renameTitle: '重命名宠物',
renamePlaceholder: '给宠物起个名字',
renameSave: '保存',
exportPet: name => `导出 ${name}`,
adoptFailed: slug => `无法领养 ${slug}`,
uninstallFailed: slug => `无法卸载 ${slug}`,
renameFailed: slug => `无法重命名 ${slug}`,
exportFailed: slug => `无法导出 ${slug}`,
noneAvailable: '当前没有可开启的宠物。',
turnOnFailed: '无法开启宠物。',
turnOffFailed: '无法关闭宠物。'
}
importedBadge: '已导入'
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@@ -950,48 +912,8 @@ export const zh: Translations = {
commandCenter: '命令中心',
appearance: '外观',
settings: '设置',
changeTheme: '更改主题',
changeTheme: '更改主题...',
changeColorMode: '更改颜色模式...',
pets: {
title: '宠物',
placeholder: '搜索宠物…',
loading: '正在加载 petdex 画廊…',
error: '无法连接到 petdex 画廊。',
staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。',
empty: '没有匹配的宠物。',
turnOff: '关闭',
turnOn: '开启',
installed: '已安装',
generatedTag: '生成',
adoptFailed: '无法领养该宠物。',
toggleFailed: '无法切换宠物显示。',
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
},
generatePet: {
title: '生成宠物',
placeholder: '描述要生成的宠物……',
promptHint: '输入描述,然后按 Enter 生成四种造型。',
readyHint: '按 Enter 根据描述生成四种造型。',
generate: '生成',
generating: '生成中……',
retry: '重试',
hatch: '孵化',
spawning: '召唤中……',
hatching: '正在孵化你的宠物……',
hatchingSub: '正在注入生命……',
hatched: '孵化成功!',
hatchRow: (_state, done, total) => `正在绘制画面…… ${done}/${total}`,
hatchComposing: '正在拼合……',
hatchSaving: '马上就好……',
namePlaceholder: '给宠物起个名字',
staleBackend: '请更新 Hermes 以生成宠物。',
backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。',
genericError: '生成失败——请重试或选择一个建议。',
referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。',
referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。',
adopt: '领养',
startOver: '重新开始'
},
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',
@@ -1648,13 +1570,11 @@ export const zh: Translations = {
manualBody: '你是从命令行安装的 Hermes因此更新也需要在那里运行。请将此命令粘贴到终端',
manualPickedUp: '下次启动 Hermes 时会使用新版本。',
guiSkewTitle: '请更新桌面应用',
guiSkewBody:
'后端已更新,但此桌面应用包未更改。请更新或重新安装 Hermes 桌面应用(你的 AppImage / .deb / .rpm以保持一致。',
guiSkewBody: '后端已更新,但此桌面应用包未更改。请更新或重新安装 Hermes 桌面应用(你的 AppImage / .deb / .rpm以保持一致。',
copy: '复制',
copied: '已复制',
done: '完成',
applyingBody:
'Hermes 更新器会在自己的窗口中接管,并在完成后自动重新打开 Hermes。更新期间请不要自行重新打开 Hermes。',
applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后自动重新打开 Hermes。更新期间请不要自行重新打开 Hermes。',
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
applyingClose: '此窗口会在更新期间关闭,随后 Hermes 会自动重新打开。',
errorTitle: '更新未完成',
@@ -2078,55 +1998,7 @@ export const zh: Translations = {
statusRunning: '运行中',
statusError: '错误',
statusRecovered: '已恢复',
statusDone: '完成',
actions: {
read: '已读取',
reading: '正在读取',
opened: '已打开',
opening: '正在打开',
searched: '已搜索',
searching: '正在搜索',
ran: '已运行',
running: '正在运行',
ranCode: '已运行代码',
runningCode: '正在编写脚本'
},
prefixes: {
browser: '浏览器',
web: '网页'
},
titleTemplates: {
actionCommand: (action, command) => `${action} · ${command}`,
actionQuoted: (action, value) => `${action}${value}`,
actionTarget: (action, target) => `${action} ${target}`,
prefixedDone: (prefix, action) => `${prefix}${action}`,
runningPrefixedTool: (prefix, action) => `正在运行${prefix}${action}`,
runningTool: action => `正在运行 ${action}`
},
titles: {
browser_click: { done: '已点击页面元素', pending: '正在点击页面元素', pendingAction: '正在点击' },
browser_fill: { done: '已填写表单字段', pending: '正在填写表单字段', pendingAction: '正在填写' },
browser_navigate: { done: '已打开页面', pending: '正在打开页面', pendingAction: '正在打开' },
browser_snapshot: { done: '已捕获页面快照', pending: '正在捕获页面快照', pendingAction: '正在捕获' },
browser_take_screenshot: { done: '已捕获截图', pending: '正在捕获截图', pendingAction: '正在捕获' },
browser_type: { done: '已在页面输入', pending: '正在页面输入', pendingAction: '正在输入' },
clarify: { done: '已提问', pending: '正在提问', pendingAction: '正在提问' },
cronjob: { done: 'Cron 任务', pending: '正在安排 Cron 任务', pendingAction: '正在安排' },
edit_file: { done: '已编辑文件', pending: '正在编辑文件', pendingAction: '正在编辑' },
execute_code: { done: '已运行代码', pending: '正在编写脚本', pendingAction: '正在编写脚本' },
image_generate: { done: '已生成图片', pending: '正在生成图片', pendingAction: '正在生成' },
list_files: { done: '已列出文件', pending: '正在列出文件', pendingAction: '正在列出' },
patch: { done: '已修补文件', pending: '正在修补文件', pendingAction: '正在修补' },
read_file: { done: '已读取文件', pending: '正在读取文件', pendingAction: '正在读取' },
search_files: { done: '已搜索文件', pending: '正在搜索文件', pendingAction: '正在搜索' },
session_search_recall: { done: '已搜索会话历史', pending: '正在搜索会话历史', pendingAction: '正在搜索' },
terminal: { done: '已运行命令', pending: '正在运行命令', pendingAction: '正在运行' },
todo: { done: '已更新待办', pending: '正在更新待办', pendingAction: '正在更新' },
vision_analyze: { done: '已分析图片', pending: '正在分析图片', pendingAction: '正在分析' },
web_extract: { done: '已读取网页', pending: '正在读取网页', pendingAction: '正在读取' },
web_search: { done: '已搜索网页', pending: '正在搜索网页', pendingAction: '正在搜索' },
write_file: { done: '已编辑文件', pending: '正在编辑文件', pendingAction: '正在编辑' }
}
statusDone: '完成'
}
},

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { ComposerAttachment } from '@/store/composer'
import { attachmentDisplayText, coerceThinkingText, optimisticAttachmentRef, parseCommandDispatch } from './chat-runtime'
import { coerceThinkingText, optimisticAttachmentRef, parseCommandDispatch } from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
@@ -36,32 +36,6 @@ describe('optimisticAttachmentRef', () => {
'@file:src/a.ts'
)
})
// Session switches / draft restores can leave undefined|null holes in the
// composer attachments array. AttachmentList already filters them (#49624),
// but the submit path maps the same array through these helpers — an unguarded
// hole threw "Cannot read properties of undefined (reading 'refText')",
// crashing the chat surface (blank pane). The helpers must no-op on holes.
it('returns null for an undefined attachment instead of throwing', () => {
expect(() => optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).not.toThrow()
expect(optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).toBeNull()
})
it('returns null for a null attachment instead of throwing', () => {
expect(optimisticAttachmentRef(null as unknown as ComposerAttachment)).toBeNull()
})
})
describe('attachmentDisplayText', () => {
it('returns null for undefined|null instead of reading .kind/.refText on a hole', () => {
expect(() => attachmentDisplayText(undefined as unknown as ComposerAttachment)).not.toThrow()
expect(attachmentDisplayText(undefined as unknown as ComposerAttachment)).toBeNull()
expect(attachmentDisplayText(null as unknown as ComposerAttachment)).toBeNull()
})
it('still resolves a normal file ref', () => {
expect(attachmentDisplayText(attachment({ kind: 'file', refText: '@file:src/a.ts' }))).toBe('@file:src/a.ts')
})
})
describe('coerceThinkingText', () => {

View File

@@ -155,13 +155,6 @@ export function pathLabel(path: string): string {
}
export function attachmentDisplayText(attachment: ComposerAttachment): string | null {
// Session switches / draft restores can leave undefined holes in the
// composer attachments array (see AttachmentList's filter(Boolean) + #49624).
// Every consumer funnels through here, so guard the chokepoint too.
if (!attachment) {
return null
}
if (attachment.kind === 'terminal' && attachment.detail) {
return `\`\`\`terminal\n${attachment.detail.trim()}\n\`\`\``
}
@@ -195,10 +188,6 @@ export function attachmentDisplayText(attachment: ComposerAttachment): string |
* through to `attachmentDisplayText`.
*/
export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null {
if (!attachment) {
return null
}
if (attachment.kind === 'image' && attachment.previewUrl?.startsWith('data:')) {
return attachment.previewUrl
}

View File

@@ -52,16 +52,6 @@ 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.

View File

@@ -32,10 +32,8 @@ export type DesktopActionId =
| 'branch'
| 'browser'
| 'handoff'
| 'hatch'
| 'help'
| 'new'
| 'pet'
| 'profile'
| 'skin'
| 'title'
@@ -130,8 +128,6 @@ 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: '/hatch', description: 'Generate a new pet (opens the pet generator)', aliases: ['/generate-pet'], surface: action('hatch') },
{ 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() },
@@ -159,7 +155,7 @@ const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> =
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills', '/pets'],
settings: ['/skills'],
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
}

View File

@@ -29,7 +29,6 @@ import {
IconCopy as CopyIcon,
IconCpu as Cpu,
IconDownload as Download,
IconEgg as Egg,
IconExternalLink as ExternalLink,
IconEye as Eye,
IconEyeOff as EyeOff,
@@ -52,7 +51,6 @@ import {
IconLoader2 as Loader2Icon,
IconLock as Lock,
IconLogin as LogIn,
IconMail as Mail,
IconMessageCircle as MessageCircle,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@@ -69,7 +67,6 @@ import {
IconLayoutBottombar as PanelBottom,
IconLayoutSidebar as PanelLeftIcon,
IconPlayerPause as Pause,
IconPaw as PawPrint,
IconPencil as Pencil,
IconPencil as PencilIcon,
IconPencil as PencilLine,
@@ -134,7 +131,6 @@ export {
CopyIcon,
Cpu,
Download,
Egg,
ExternalLink,
Eye,
EyeOff,
@@ -157,7 +153,6 @@ export {
Loader2Icon,
Lock,
LogIn,
Mail,
MessageCircle,
MessageSquareText,
Mic,
@@ -174,7 +169,6 @@ export {
PanelBottom,
PanelLeftIcon,
Pause,
PawPrint,
Pencil,
PencilIcon,
PencilLine,

View File

@@ -5,8 +5,6 @@
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
// add a hotkey, add a row here and a handler there — nothing else.
import { IS_MAC } from './combo'
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in
@@ -57,12 +55,6 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Composer ─────────────────────────────────────────────────────────────
{ id: 'composer.focus', category: 'composer', defaults: [] },
{ id: 'composer.modelPicker', category: 'composer', defaults: [] },
// Voice conversation toggle. Matches the documented `voice.record_key`
// (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar
// toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar
// chord, so ship it unbound there (rebindable in the panel) rather than
// stealing the long-standing sidebar binding.
{ id: 'composer.voice', category: 'composer', defaults: IS_MAC ? ['ctrl+b'] : [] },
// ── Profiles ─────────────────────────────────────────────────────────────
{ id: 'profile.default', category: 'profiles', defaults: ['mod+d'] },

Some files were not shown because too many files have changed in this diff Show More