Compare commits
257 Commits
feat/plugi
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76fa55240d | ||
|
|
a09343cc96 | ||
|
|
f456f302df | ||
|
|
8972a151a4 | ||
|
|
a2d7f538d4 | ||
|
|
9c16ca8790 | ||
|
|
4717989c10 | ||
|
|
73dd584995 | ||
|
|
3edd09a46f | ||
|
|
875aa8f162 | ||
|
|
85503dceca | ||
|
|
955fa40062 | ||
|
|
0d3e2cc539 | ||
|
|
c94e93a648 | ||
|
|
39f40ece70 | ||
|
|
0edeee14c6 | ||
|
|
b4fbf7b93c | ||
|
|
9662b76d59 | ||
|
|
899acfe42f | ||
|
|
ed2b9e43c8 | ||
|
|
cedd9b6d47 | ||
|
|
dd40600e0a | ||
|
|
5e81113d09 | ||
|
|
04b3f19538 | ||
|
|
b8e2c16579 | ||
|
|
4829f8d2c5 | ||
|
|
cb2c13055e | ||
|
|
264ac72b67 | ||
|
|
f38f7a3870 | ||
|
|
2450fd7066 | ||
|
|
0b5b7ddfd2 | ||
|
|
fa7f24e898 | ||
|
|
13f1efdd15 | ||
|
|
4d22b82933 | ||
|
|
419c8a98a9 | ||
|
|
975edd4140 | ||
|
|
d7d281fa37 | ||
|
|
292192f7d7 | ||
|
|
c710868fbc | ||
|
|
3e74f75e41 | ||
|
|
fdc0d19566 | ||
|
|
7d8d000b19 | ||
|
|
68ffedb6a9 | ||
|
|
efcbbde48c | ||
|
|
7a1eed8268 | ||
|
|
529bb1c3d5 | ||
|
|
aaccaada28 | ||
|
|
65ddc7c4a1 | ||
|
|
ad9012097b | ||
|
|
914befa9aa | ||
|
|
3d14f01fd6 | ||
|
|
18d61bd06e | ||
|
|
acd7932c0f | ||
|
|
0a5762c78d | ||
|
|
e0e2571711 | ||
|
|
fe54960142 | ||
|
|
3ffbdfbcc0 | ||
|
|
615ad97928 | ||
|
|
9dd9ef0ec9 | ||
|
|
4490c7cf8d | ||
|
|
e96ca1a0d3 | ||
|
|
d1383a6b14 | ||
|
|
0a593f132c | ||
|
|
3b4c715e1c | ||
|
|
da818510ec | ||
|
|
590b3c0d7e | ||
|
|
88fcf0c8c0 | ||
|
|
f7a6d6a6a1 | ||
|
|
acd4f34e65 | ||
|
|
1e7316ced2 | ||
|
|
a8f404b29f | ||
|
|
2d75833abe | ||
|
|
9f95f72b98 | ||
|
|
86e10dd874 | ||
|
|
6110aed9be | ||
|
|
6de3963e37 | ||
|
|
07ac185904 | ||
|
|
3acf73161f | ||
|
|
dd60c49bb8 | ||
|
|
6fe4821926 | ||
|
|
d986bb0c6d | ||
|
|
4cecb1a13a | ||
|
|
90f4b3040d | ||
|
|
3bfbb3f2a0 | ||
|
|
a72bb03757 | ||
|
|
47e77ae166 | ||
|
|
4c797d0e23 | ||
|
|
189ffe7362 | ||
|
|
2c19208224 | ||
|
|
5718811de0 | ||
|
|
af3c8b80b5 | ||
|
|
70d5d7e39b | ||
|
|
a5c32cdf30 | ||
|
|
15813336cc | ||
|
|
183d86b3e0 | ||
|
|
cd9a9cd8e5 | ||
|
|
5d8c44a393 | ||
|
|
2f19512341 | ||
|
|
f222bd26e7 | ||
|
|
38273676ea | ||
|
|
c1308ebf3f | ||
|
|
fa32af886f | ||
|
|
984e69ff62 | ||
|
|
e80754647c | ||
|
|
298bb93d39 | ||
|
|
eee1da45f0 | ||
|
|
6a30cfca82 | ||
|
|
888bf96025 | ||
|
|
383d44bc9a | ||
|
|
243cada157 | ||
|
|
af978ecb17 | ||
|
|
4eadef18a9 | ||
|
|
099146fedd | ||
|
|
e5580f43c2 | ||
|
|
5a4297a11a | ||
|
|
aea0b7397b | ||
|
|
311900842e | ||
|
|
105625d650 | ||
|
|
2ce3ae3d16 | ||
|
|
19c07c4037 | ||
|
|
ab55008631 | ||
|
|
1c055a4c58 | ||
|
|
095f526b11 | ||
|
|
9ca9697342 | ||
|
|
63a421d4c0 | ||
|
|
e4a1b35a39 | ||
|
|
ea7981eba7 | ||
|
|
f1b8519670 | ||
|
|
f8fd30942c | ||
|
|
1967c590ed | ||
|
|
702f4df194 | ||
|
|
0092015496 | ||
|
|
9caa12f4ec | ||
|
|
4642762289 | ||
|
|
bf7abc2f73 | ||
|
|
d03cdd63eb | ||
|
|
96af61b6ef | ||
|
|
7803cbfbb9 | ||
|
|
45e1689c03 | ||
|
|
fdc90346ea | ||
|
|
f082b4ec5c | ||
|
|
833410e02b | ||
|
|
6b330522e1 | ||
|
|
1770263ccc | ||
|
|
33a5bfa3c4 | ||
|
|
8f73d0d945 | ||
|
|
27a3211579 | ||
|
|
5cf6e28a2f | ||
|
|
b4170f3ac2 | ||
|
|
7df3aa34b1 | ||
|
|
b96bd4808d | ||
|
|
d33965396e | ||
|
|
258d24039f | ||
|
|
ab5f1a1f11 | ||
|
|
8bb6529553 | ||
|
|
29036155ce | ||
|
|
8b84d82227 | ||
|
|
93340fa3c1 | ||
|
|
59ea2f98e6 | ||
|
|
aecdacb11b | ||
|
|
7ffc216bc0 | ||
|
|
218452b050 | ||
|
|
29147afd63 | ||
|
|
b021497bc8 | ||
|
|
891c9a6823 | ||
|
|
72154ad879 | ||
|
|
153060e206 | ||
|
|
4906dcfc25 | ||
|
|
57c6714995 | ||
|
|
a5d05cf30e | ||
|
|
68a997fed4 | ||
|
|
49dd776d8b | ||
|
|
d7886da08c | ||
|
|
02f878ec5a | ||
|
|
8d71c38919 | ||
|
|
46fedef07f | ||
|
|
ba44de06da | ||
|
|
5750d058fa | ||
|
|
1febb08240 | ||
|
|
39b76d9013 | ||
|
|
52f7e24a74 | ||
|
|
b8eede7bda | ||
|
|
967c325da8 | ||
|
|
f6f573ebaa | ||
|
|
ff9c110d5a | ||
|
|
c4811c382f | ||
|
|
c6dc2fcd21 | ||
|
|
f6416f50fc | ||
|
|
92dfd70d6a | ||
|
|
b5421f4ba6 | ||
|
|
d046169646 | ||
|
|
57775e9e16 | ||
|
|
3a74b75217 | ||
|
|
24a934295f | ||
|
|
ffcd9d7ac7 | ||
|
|
be2f739e9a | ||
|
|
72f522d464 | ||
|
|
cb4cc08b0a | ||
|
|
85852b71d8 | ||
|
|
8d99b5bc4f | ||
|
|
a38cc69bcc | ||
|
|
76f89d66de | ||
|
|
f8adefdebf | ||
|
|
dbbd1d4d05 | ||
|
|
e687292eb4 | ||
|
|
c4066091ca | ||
|
|
50ad191a8b | ||
|
|
520b59db16 | ||
|
|
4b073d0906 | ||
|
|
dbf2470d46 | ||
|
|
9fb83eaa2f | ||
|
|
0337658904 | ||
|
|
b58ff93459 | ||
|
|
2130ef68b3 | ||
|
|
637cf94bed | ||
|
|
9351cbafab | ||
|
|
18ead88273 | ||
|
|
dba6380ca6 | ||
|
|
ba622d44e4 | ||
|
|
2c1aaa9cba | ||
|
|
8bb60ff039 | ||
|
|
bddab61bcb | ||
|
|
d1f23bb2d5 | ||
|
|
54318c65b0 | ||
|
|
c1927d2342 | ||
|
|
3705625b74 | ||
|
|
3dcfbbfc49 | ||
|
|
3b983e7791 | ||
|
|
0d25cae041 | ||
|
|
e79e44af79 | ||
|
|
fdf48c63c8 | ||
|
|
0646656884 | ||
|
|
92179352fb | ||
|
|
e9b26c7c8b | ||
|
|
84e4b4b9a5 | ||
|
|
314af28e86 | ||
|
|
b3aef57f21 | ||
|
|
4e4d27875f | ||
|
|
c3420d91ad | ||
|
|
0c2e81df00 | ||
|
|
a46462ec65 | ||
|
|
b23184cad4 | ||
|
|
52ae9d9f02 | ||
|
|
1e5ff4a577 | ||
|
|
6a8dda171c | ||
|
|
e0f6a35ac6 | ||
|
|
b5f8996ccc | ||
|
|
714183530b | ||
|
|
ab98818e5b | ||
|
|
d66bac5a1a | ||
|
|
300371c3f2 | ||
|
|
f4531feee8 | ||
|
|
6d2732e786 | ||
|
|
aa424e51ac | ||
|
|
732ababa1a | ||
|
|
421226e404 | ||
|
|
37561c214b |
@@ -63,3 +63,45 @@ data/
|
|||||||
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
||||||
hermes-config/
|
hermes-config/
|
||||||
runtime/
|
runtime/
|
||||||
|
|
||||||
|
# ---------- Not needed inside the Docker image ----------
|
||||||
|
|
||||||
|
# Desktop app source (Tauri/Electron); never installed in the container
|
||||||
|
apps/
|
||||||
|
|
||||||
|
# Test suite — not shipped in production images
|
||||||
|
tests/
|
||||||
|
|
||||||
|
# Documentation site (Docusaurus) and supplementary docs
|
||||||
|
website/
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Assets only used by the GitHub README
|
||||||
|
assets/
|
||||||
|
infographic/
|
||||||
|
|
||||||
|
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
|
||||||
|
plugins/hermes-achievements/docs/
|
||||||
|
|
||||||
|
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
|
||||||
|
nix/
|
||||||
|
flake.nix
|
||||||
|
flake.lock
|
||||||
|
packaging/
|
||||||
|
|
||||||
|
# Design and planning documents
|
||||||
|
plans/
|
||||||
|
.plans/
|
||||||
|
|
||||||
|
# ACP registry manifest (icon + agent.json) — not consumed at runtime
|
||||||
|
acp_registry/
|
||||||
|
|
||||||
|
# Repo-level dotfiles that are git-only or dev-tooling config
|
||||||
|
.env.example
|
||||||
|
.envrc
|
||||||
|
.gitattributes
|
||||||
|
.hadolint.yaml
|
||||||
|
.mailmap
|
||||||
|
|
||||||
|
# Top-level LICENSE (not matched by *.md); not needed inside the container
|
||||||
|
LICENSE
|
||||||
|
|||||||
BIN
.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg
vendored
Normal file
|
After Width: | Height: | Size: 428 KiB |
2
.github/workflows/deploy-site.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: website/package-lock.json
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/docs-site-checks.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: website/package-lock.json
|
||||||
|
|
||||||
|
|||||||
48
.github/workflows/tests.yml
vendored
@@ -55,15 +55,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||||
|
with:
|
||||||
|
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||||
|
# Keyed on the dependency manifests, so the cache is reused until
|
||||||
|
# pyproject.toml or uv.lock changes. `uv sync` still runs every
|
||||||
|
# time, but resolves from the warm cache instead of re-downloading
|
||||||
|
# and re-building wheels.
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: |
|
||||||
|
pyproject.toml
|
||||||
|
uv.lock
|
||||||
|
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||||||
uv venv .venv --python 3.11
|
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||||
source .venv/bin/activate
|
# reproducible env. It also creates .venv itself, so no separate
|
||||||
uv pip install -e ".[all,dev]"
|
# `uv venv` step is needed.
|
||||||
|
run: uv sync --locked --python 3.11 --extra all --extra dev
|
||||||
|
|
||||||
|
- name: Minimize uv cache
|
||||||
|
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||||
|
# re-download, keeping the persisted cache small and fast to restore.
|
||||||
|
run: uv cache prune --ci
|
||||||
|
|
||||||
- name: Run tests (slice ${{ matrix.slice }}/6)
|
- name: Run tests (slice ${{ matrix.slice }}/6)
|
||||||
# Per-file isolation via scripts/run_tests_parallel.py: discovers
|
# Per-file isolation via scripts/run_tests_parallel.py: discovers
|
||||||
@@ -161,15 +177,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||||
|
with:
|
||||||
|
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||||
|
# Keyed on the dependency manifests, so the cache is reused until
|
||||||
|
# pyproject.toml or uv.lock changes. `uv sync` still runs every
|
||||||
|
# time, but resolves from the warm cache instead of re-downloading
|
||||||
|
# and re-building wheels.
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: |
|
||||||
|
pyproject.toml
|
||||||
|
uv.lock
|
||||||
|
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||||||
uv venv .venv --python 3.11
|
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||||
source .venv/bin/activate
|
# reproducible env. It also creates .venv itself, so no separate
|
||||||
uv pip install -e ".[all,dev]"
|
# `uv venv` step is needed.
|
||||||
|
run: uv sync --locked --python 3.11 --extra all --extra dev
|
||||||
|
|
||||||
|
- name: Minimize uv cache
|
||||||
|
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||||
|
# re-download, keeping the persisted cache small and fast to restore.
|
||||||
|
run: uv cache prune --ci
|
||||||
|
|
||||||
- name: Packaged-wheel i18n smoke test
|
- name: Packaged-wheel i18n smoke test
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
25
.github/workflows/typecheck.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# .github/workflows/typecheck.yml
|
||||||
|
name: Typecheck
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
package:
|
||||||
|
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||||
|
fail-fast: false # report all failures, not just the first one
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||||
6
.gitignore
vendored
@@ -114,6 +114,12 @@ docs/superpowers/*
|
|||||||
# treat it as a local edit and autostash it on every run (#38529).
|
# treat it as a local edit and autostash it on every run (#38529).
|
||||||
.hermes-bootstrap-complete
|
.hermes-bootstrap-complete
|
||||||
|
|
||||||
|
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
|
||||||
|
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
||||||
|
# — ignore so `git status` stays clean and update's autostash skips them.
|
||||||
|
.update-incomplete
|
||||||
|
.update-incomplete.lock
|
||||||
|
|
||||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||||
scripts/out/
|
scripts/out/
|
||||||
|
|||||||
205
AGENTS.md
@@ -4,6 +4,201 @@ Instructions for AI coding assistants and developers working on the hermes-agent
|
|||||||
|
|
||||||
**Never give up on the right solution.**
|
**Never give up on the right solution.**
|
||||||
|
|
||||||
|
## What Hermes Is
|
||||||
|
|
||||||
|
Hermes is a personal AI agent that runs the same agent core across a CLI, a
|
||||||
|
messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
|
||||||
|
and an Electron desktop app. It learns across sessions (memory + skills),
|
||||||
|
delegates to subagents, runs scheduled jobs, and drives a real terminal and
|
||||||
|
browser. It is extended primarily through **plugins and skills**, not by
|
||||||
|
growing the core.
|
||||||
|
|
||||||
|
Two properties shape almost every design decision and are the lens for
|
||||||
|
reviewing any change:
|
||||||
|
|
||||||
|
- **Per-conversation prompt caching is sacred.** A long-lived conversation
|
||||||
|
reuses a cached prefix every turn. Anything that mutates past context,
|
||||||
|
swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
|
||||||
|
that cache and multiplies the user's cost. We do not do it (the one
|
||||||
|
exception is context compression).
|
||||||
|
- **The core is a narrow waist; capability lives at the edges.** Every model
|
||||||
|
tool we add is sent on every API call, so the bar for a new *core* tool is
|
||||||
|
high. Most new capability should arrive as a CLI command + skill, a
|
||||||
|
service-gated tool, or a plugin — not as core surface.
|
||||||
|
|
||||||
|
## Contribution Rubric — What We Want / What We Don't
|
||||||
|
|
||||||
|
This is the project's intent layer. Use it two ways:
|
||||||
|
|
||||||
|
1. **For humans and for your own work** — what gets merged and what gets
|
||||||
|
rejected, so a contribution aims at the target.
|
||||||
|
2. **For automated review (the triage sweeper)** — guidance on when a PR is
|
||||||
|
safe to close on the three allowed reasons (`implemented_on_main`,
|
||||||
|
`cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
|
||||||
|
close** one. Taste-based "we don't want this / out of scope" closes are NOT
|
||||||
|
an automated decision — those stay with a human maintainer. The sweeper's
|
||||||
|
job here is to recognize design intent and *avoid wrongly closing a
|
||||||
|
legitimate contribution*, not to make the won't-implement call itself.
|
||||||
|
|
||||||
|
Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
|
||||||
|
real reported behavior, and the product surface (platforms, channels,
|
||||||
|
providers, models, desktop/TUI features) expands aggressively and on purpose.
|
||||||
|
The restraint below is aimed squarely at the **core agent + the model tool
|
||||||
|
schema**, the one place where every addition is paid for on every API call.
|
||||||
|
"Smallest footprint" governs *how a capability is wired into the core*, NOT
|
||||||
|
whether the product is allowed to grow. We are expansive at the edges and
|
||||||
|
conservative at the waist.
|
||||||
|
|
||||||
|
### What we want
|
||||||
|
|
||||||
|
- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
|
||||||
|
actual reported symptom. A good fix reproduces the symptom on current
|
||||||
|
`main`, points to the exact line where it manifests, and fixes the whole bug
|
||||||
|
class — sibling call paths included — not just the one site the reporter hit.
|
||||||
|
- **Expand reach at the edges.** New platform adapters, channels, providers,
|
||||||
|
models, and desktop/TUI/dashboard features are welcome and land routinely,
|
||||||
|
including large ones (a new messaging channel, a session-cap feature, a
|
||||||
|
Windows PTY bridge). Breadth in the product is a goal, not a footprint
|
||||||
|
concern — as long as it integrates with the existing setup/config UX
|
||||||
|
(`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
|
||||||
|
env var.
|
||||||
|
- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
|
||||||
|
cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
|
||||||
|
mixin or module is wanted work, even when the diff is huge and mechanical
|
||||||
|
(large `+N/-N` refactors merge regularly). The "every line traces to the
|
||||||
|
request" test applies to *feature* PRs; a declared refactor's request IS the
|
||||||
|
extraction.
|
||||||
|
- **Keep the core narrow.** New *model tools* are the expensive exception —
|
||||||
|
every tool ships on every API call. Prefer, in order: extend existing code →
|
||||||
|
CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
|
||||||
|
in the catalog → new core tool (last resort). See "The Footprint Ladder."
|
||||||
|
- **Extend, don't duplicate.** Before adding a module/manager/hook, check
|
||||||
|
whether existing infrastructure already covers the use case. When several PRs
|
||||||
|
integrate the same *category*, design one shared interface instead of merging
|
||||||
|
them one at a time (see the ABC + orchestrator note under the Footprint
|
||||||
|
Ladder).
|
||||||
|
- **Behavior contracts over snapshots.** Tests should assert how two pieces of
|
||||||
|
data must relate (invariants), not freeze a current value (model lists,
|
||||||
|
config version literals, enumeration counts). See "Don't write
|
||||||
|
change-detector tests."
|
||||||
|
- **E2E validation, not just green unit mocks.** For anything touching
|
||||||
|
resolution chains, config propagation, security boundaries, remote
|
||||||
|
backends, or file/network I/O, exercise the real path with real imports
|
||||||
|
against a temp `HERMES_HOME`. Mocks hide integration bugs.
|
||||||
|
- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
|
||||||
|
message role alternation (never two same-role messages in a row; never a
|
||||||
|
synthetic user message injected mid-loop), and a system prompt that is
|
||||||
|
byte-stable for the life of a conversation.
|
||||||
|
- **Contributor credit preserved.** Salvage external work by cherry-picking
|
||||||
|
(rebase-merge) so authorship survives in git history; don't reimplement from
|
||||||
|
scratch when you can build on top.
|
||||||
|
|
||||||
|
### What we don't want (rejected even when well-built)
|
||||||
|
|
||||||
|
- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
|
||||||
|
concrete consumer. Adding a hook is easy; removing one after plugins depend
|
||||||
|
on it is hard. A hook is NOT speculative if a contributor has a real, stated
|
||||||
|
use case — even if the consumer ships separately.
|
||||||
|
- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
|
||||||
|
only (API keys, tokens, passwords). All behavioral settings — timeouts,
|
||||||
|
thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
|
||||||
|
internal env var if the mechanism needs one, but user-facing docs point to
|
||||||
|
`config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
|
||||||
|
is a credential.
|
||||||
|
- **A new core tool when terminal + file already do the job, or when a skill
|
||||||
|
would.** If the only barrier is file visibility on a remote backend, fix the
|
||||||
|
mount, not the toolset.
|
||||||
|
- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
|
||||||
|
pagination on tools that load content the agent must read fully (skills,
|
||||||
|
prompts, playbooks). Models will read page 1 and skip the rest.
|
||||||
|
- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
|
||||||
|
feature's purpose is the wrong mitigation. Read the original commit's intent
|
||||||
|
(`git log -p -S`) before restricting behavior; find a fix that preserves the
|
||||||
|
feature.
|
||||||
|
- **Outbound telemetry / usage attribution without opt-in gating.** No new
|
||||||
|
analytics, third-party identifier tagging, or attribution tags until a
|
||||||
|
generic user-facing opt-in (config gate + setup prompt + `hermes tools`
|
||||||
|
toggle) exists. Park behind a label, do not merge.
|
||||||
|
- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
|
||||||
|
without E2E proof, and plugins that touch core files.** Plugins live in their
|
||||||
|
own directory and work within the ABCs/hooks we provide; if a plugin needs
|
||||||
|
more, widen the generic plugin surface, don't special-case it in core.
|
||||||
|
|
||||||
|
### Before you call it a bug — verify the premise (and when NOT to close)
|
||||||
|
|
||||||
|
The most common reason a well-written PR gets closed is not code quality — it
|
||||||
|
is that the change is built on a **wrong premise**, or it treats an
|
||||||
|
**intentional design as a gap**. These patterns cut both ways: they tell a
|
||||||
|
human reviewer what to scrutinize, and they tell the automated sweeper when a
|
||||||
|
PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
|
||||||
|
doubt, leave it open for a human). They are distilled from real closes.
|
||||||
|
|
||||||
|
- **"Intentional design, not a gap."** A limitation that looks like an
|
||||||
|
oversight is often deliberate. Before "fixing" a missing link or a
|
||||||
|
restriction, ask whether the isolation IS the design. Example: profiles are
|
||||||
|
independent islands on purpose — a PR adding live config inheritance from the
|
||||||
|
default profile was closed because coupling profiles together is exactly what
|
||||||
|
the design prevents (the copy-at-creation `--clone` path already covers the
|
||||||
|
legitimate "start from my default" case). Read the original commit's intent
|
||||||
|
(`git log -p -S "<symbol>"`) before assuming something is unfinished.
|
||||||
|
- **"The premise doesn't hold against how X actually works."** A PR's
|
||||||
|
justification frequently rests on a wrong mental model of an existing
|
||||||
|
mechanism. Trace the real code/runtime before accepting the rationale. Two
|
||||||
|
real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
|
||||||
|
trips on a *confirmed-empty* account bucket, so re-probing just hammers a
|
||||||
|
bucket we've already proven empty); a usage-accumulation fix whose new branch
|
||||||
|
**never executes at runtime** because an earlier guard already popped the
|
||||||
|
state it depended on. If you can't point to the exact line where the bug
|
||||||
|
manifests AND show the fix changes that line's behavior, you haven't verified
|
||||||
|
the premise.
|
||||||
|
- **"This fix was wrong — the absence/omission was deliberate."** Adding the
|
||||||
|
obvious-looking missing piece can break things the omission was protecting.
|
||||||
|
Example: restoring "missing" `__init__.py` files made a test tree importable
|
||||||
|
as a dotted package that shadowed the real plugin, deleting its `register()`
|
||||||
|
at import time. The absence was load-bearing.
|
||||||
|
- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
|
||||||
|
supersedes an agreed-on base, or revives a direction the maintainers
|
||||||
|
deliberately closed, gets rejected even when the code works. Keep the change
|
||||||
|
to the narrow piece that was actually agreed; offer the rest as a focused
|
||||||
|
follow-up.
|
||||||
|
|
||||||
|
The throughline: **verify the claim AND the intent against the codebase before
|
||||||
|
writing or merging a fix.** A confirmed reproduction on current `main` plus a
|
||||||
|
line-level account of where the fix acts beats a plausible-sounding rationale
|
||||||
|
every time. When in doubt about intent, it is cheaper to ask than to ship a
|
||||||
|
fix that fights the design.
|
||||||
|
|
||||||
|
### The Footprint Ladder (new capability decision)
|
||||||
|
|
||||||
|
Each rung adds more permanent surface than the one above. Choose the highest
|
||||||
|
(least-footprint) rung that correctly solves the problem:
|
||||||
|
|
||||||
|
1. **Extend existing code** — the capability is a variation of something that
|
||||||
|
already exists. Zero new surface.
|
||||||
|
2. **CLI command + skill** — manages config/state/infra expressible as shell
|
||||||
|
commands. The agent runs `hermes <subcommand>` guided by a skill. Zero
|
||||||
|
model-tool footprint. Default choice for subscriptions, scheduled tasks,
|
||||||
|
service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
|
||||||
|
3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
|
||||||
|
only appears when a prerequisite is configured. Zero footprint otherwise.
|
||||||
|
Examples: Home Assistant tools (gated on token), memory-provider tools.
|
||||||
|
4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
|
||||||
|
core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
|
||||||
|
5. **MCP server (in the catalog)** — if the capability genuinely needs to be a
|
||||||
|
tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
|
||||||
|
building it as an MCP server and adding it to the MCP catalog over growing
|
||||||
|
the core toolset. The agent connects to it through the built-in MCP client;
|
||||||
|
zero permanent core-schema footprint, and it's reusable by any MCP host.
|
||||||
|
6. **New core tool** — only when the capability is fundamental, broadly useful
|
||||||
|
to nearly every user, and unreachable via terminal + file (or an MCP server).
|
||||||
|
Examples of correct core tools: terminal, read_file, web_search,
|
||||||
|
browser_navigate.
|
||||||
|
|
||||||
|
When 3+ open PRs try to integrate the same *category* of thing (memory
|
||||||
|
backends, providers, notifiers), don't merge them one at a time — design an
|
||||||
|
ABC + orchestrator, wrap the existing built-in as the first provider, and turn
|
||||||
|
the competing PRs into plugins against that interface.
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -264,7 +459,7 @@ npm install # first time
|
|||||||
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
|
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
|
||||||
npm start # production
|
npm start # production
|
||||||
npm run build # full build (hermes-ink + tsc)
|
npm run build # full build (hermes-ink + tsc)
|
||||||
npm run type-check # typecheck only (tsc --noEmit)
|
npm run typecheck # typecheck only (tsc --noEmit)
|
||||||
npm run lint # eslint
|
npm run lint # eslint
|
||||||
npm run fmt # prettier
|
npm run fmt # prettier
|
||||||
npm test # vitest
|
npm test # vitest
|
||||||
@@ -302,9 +497,11 @@ A **separate** chat surface from both the classic CLI and the dashboard's embedd
|
|||||||
|
|
||||||
## Adding New Tools
|
## Adding New Tools
|
||||||
|
|
||||||
For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
|
Before adding any tool, settle the footprint question first (see "The
|
||||||
route instead: create `~/.hermes/plugins/<name>/plugin.yaml` and
|
Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
|
||||||
`~/.hermes/plugins/<name>/__init__.py`, then register tools with
|
be core tools. For custom or local-only tools, do **not** edit Hermes core.
|
||||||
|
Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
|
||||||
|
and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
|
||||||
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
|
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
|
||||||
enabled or disabled without touching `tools/` or `toolsets.py`.
|
enabled or disabled without touching `tools/` or `toolsets.py`.
|
||||||
|
|
||||||
|
|||||||
29
Dockerfile
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
|||||||
# hermes process, the dashboard, and per-profile gateways.
|
# hermes process, the dashboard, and per-profile gateways.
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ---------- s6-overlay install ----------
|
# ---------- s6-overlay install ----------
|
||||||
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
|
|||||||
#
|
#
|
||||||
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
||||||
# installs the deps reachable through the composite `[all]` extra
|
# installs the deps reachable through the composite `[all]` extra
|
||||||
# (handpicked set intended for the production image), plus gateway
|
# (handpicked set intended for the production image — excludes `[dev]`),
|
||||||
# messaging adapters that should work in the published image without a
|
# plus gateway messaging adapters that should work in the published image
|
||||||
# first-boot lazy install. We do NOT use `--all-extras`:
|
# without a first-boot lazy install. We do NOT use `--all-extras`:
|
||||||
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
||||||
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
||||||
# redundancy), none of which belong in the published container.
|
# redundancy), none of which belong in the published container.
|
||||||
@@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \
|
|||||||
# image update and recall/retain then fails with
|
# image update and recall/retain then fails with
|
||||||
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
||||||
#
|
#
|
||||||
|
# The Matrix gateway's deps ([matrix] extra) are baked in because
|
||||||
|
# python-olm (transitive via mautrix[encryption]) builds from source on
|
||||||
|
# Python/image combinations without usable wheels. The Docker image is
|
||||||
|
# Linux-only, so keeping the native libolm/build-toolchain packages here
|
||||||
|
# avoids the cross-platform failures that kept [matrix] out of [all]
|
||||||
|
# while still making Matrix work in the published container. Fixes #30399.
|
||||||
|
#
|
||||||
# The editable link is created after the source copy below.
|
# The editable link is created after the source copy below.
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
RUN touch ./README.md
|
RUN touch ./README.md
|
||||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
|
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
|
||||||
|
|
||||||
|
# ---------- Frontend build (cached independently from Python source) ----------
|
||||||
|
# Copy only the frontend source trees first so that Python-only changes don't
|
||||||
|
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||||
|
COPY web/ web/
|
||||||
|
COPY ui-tui/ ui-tui/
|
||||||
|
RUN cd web && npm run build && \
|
||||||
|
cd ../ui-tui && npm run build
|
||||||
|
|
||||||
# ---------- Source code ----------
|
# ---------- Source code ----------
|
||||||
# .dockerignore excludes node_modules, so the installs above survive.
|
# .dockerignore excludes node_modules, so the installs above survive.
|
||||||
COPY --chown=hermes:hermes . .
|
COPY --chown=hermes:hermes . .
|
||||||
|
|
||||||
# Build browser dashboard and terminal UI assets.
|
|
||||||
RUN cd web && npm run build && \
|
|
||||||
cd ../ui-tui && npm run build
|
|
||||||
|
|
||||||
# ---------- Permissions ----------
|
# ---------- Permissions ----------
|
||||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||||
# The venv needs to be traversable too.
|
# The venv needs to be traversable too.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
graft skills
|
graft skills
|
||||||
graft optional-skills
|
graft optional-skills
|
||||||
|
graft optional-mcps
|
||||||
graft locales
|
graft locales
|
||||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Hermes Agent ☤
|
# Hermes Agent ☤
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
|
||||||
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ def init_agent(
|
|||||||
thinking_callback: callable = None,
|
thinking_callback: callable = None,
|
||||||
reasoning_callback: callable = None,
|
reasoning_callback: callable = None,
|
||||||
clarify_callback: callable = None,
|
clarify_callback: callable = None,
|
||||||
|
read_terminal_callback: callable = None,
|
||||||
step_callback: callable = None,
|
step_callback: callable = None,
|
||||||
stream_delta_callback: callable = None,
|
stream_delta_callback: callable = None,
|
||||||
interim_assistant_callback: callable = None,
|
interim_assistant_callback: callable = None,
|
||||||
@@ -417,6 +418,7 @@ def init_agent(
|
|||||||
agent.thinking_callback = thinking_callback
|
agent.thinking_callback = thinking_callback
|
||||||
agent.reasoning_callback = reasoning_callback
|
agent.reasoning_callback = reasoning_callback
|
||||||
agent.clarify_callback = clarify_callback
|
agent.clarify_callback = clarify_callback
|
||||||
|
agent.read_terminal_callback = read_terminal_callback
|
||||||
agent.step_callback = step_callback
|
agent.step_callback = step_callback
|
||||||
agent.stream_delta_callback = stream_delta_callback
|
agent.stream_delta_callback = stream_delta_callback
|
||||||
agent.interim_assistant_callback = interim_assistant_callback
|
agent.interim_assistant_callback = interim_assistant_callback
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def _ra():
|
|||||||
|
|
||||||
|
|
||||||
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
|
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
|
||||||
{"todo", "session_search", "memory", "clarify", "delegate_task"}
|
{"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1784,6 +1784,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||||||
),
|
),
|
||||||
next_args,
|
next_args,
|
||||||
)
|
)
|
||||||
|
elif function_name == "read_terminal":
|
||||||
|
def _execute(next_args: dict) -> Any:
|
||||||
|
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
|
||||||
|
return _finish_agent_tool(
|
||||||
|
_read_terminal_tool(
|
||||||
|
start_line=next_args.get("start_line"),
|
||||||
|
count=next_args.get("count"),
|
||||||
|
callback=getattr(agent, "read_terminal_callback", None),
|
||||||
|
),
|
||||||
|
next_args,
|
||||||
|
)
|
||||||
elif function_name == "delegate_task":
|
elif function_name == "delegate_task":
|
||||||
def _execute(next_args: dict) -> Any:
|
def _execute(next_args: dict) -> Any:
|
||||||
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
||||||
|
|||||||
@@ -73,20 +73,50 @@ ADAPTIVE_EFFORT_MAP = {
|
|||||||
"minimal": "low",
|
"minimal": "low",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
|
# ── Anthropic thinking-mode classification ────────────────────────────
|
||||||
# xhigh as a distinct level between high and max; older adaptive-thinking
|
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
|
||||||
# models (4.6) reject it with a 400. Keep this substring list in sync with
|
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
|
||||||
# the Anthropic migration guide as new model families ship.
|
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
|
||||||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
# claude-fable-5) follow the same modern contract — but they share no common
|
||||||
|
# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
|
||||||
|
# stale the moment a model ships without a recognized number and silently
|
||||||
|
# routes it down the legacy manual-thinking path.
|
||||||
|
#
|
||||||
|
# Instead we DEFAULT unknown Claude models to the modern contract and keep an
|
||||||
|
# explicit *legacy* list of the older Claude families that still require manual
|
||||||
|
# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
|
||||||
|
# (future models are unlikely to regress to the older contract), so each new
|
||||||
|
# Claude release works without a code change.
|
||||||
|
#
|
||||||
|
# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
|
||||||
|
# so they fall through to the legacy path automatically — exactly what those
|
||||||
|
# manual-thinking endpoints need.
|
||||||
|
|
||||||
|
# Older Claude families that DON'T support adaptive thinking (manual thinking
|
||||||
|
# with budget_tokens only). Substring-matched against the model name.
|
||||||
|
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
|
||||||
|
"claude-3", # 3, 3.5, 3.7
|
||||||
|
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
|
||||||
|
"claude-sonnet-4-0", "claude-sonnet-4.0",
|
||||||
|
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
|
||||||
|
"claude-opus-4-5", "claude-opus-4.5",
|
||||||
|
"claude-sonnet-4-5", "claude-sonnet-4.5",
|
||||||
|
"claude-haiku-4-5", "claude-haiku-4.5",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
|
||||||
|
# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
|
||||||
|
# not in this list (4.7, 4.8, fable, future) accept xhigh.
|
||||||
|
_NO_XHIGH_CLAUDE_SUBSTRINGS = (
|
||||||
|
"claude-opus-4-6", "claude-opus-4.6",
|
||||||
|
"claude-sonnet-4-6", "claude-sonnet-4.6",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_claude_model(model: str | None) -> bool:
|
||||||
|
return "claude" in (model or "").lower()
|
||||||
|
|
||||||
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
|
|
||||||
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
|
|
||||||
# and drops temperature/top_p/top_k).
|
|
||||||
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
|
|
||||||
|
|
||||||
# Models where temperature/top_p/top_k return 400 if set to non-default values.
|
|
||||||
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
|
|
||||||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
|
||||||
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||||
|
|
||||||
# ── Max output token limits per Anthropic model ───────────────────────
|
# ── Max output token limits per Anthropic model ───────────────────────
|
||||||
@@ -94,6 +124,8 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
|||||||
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
||||||
# starves thinking-enabled models (thinking tokens count toward the limit).
|
# starves thinking-enabled models (thinking tokens count toward the limit).
|
||||||
_ANTHROPIC_OUTPUT_LIMITS = {
|
_ANTHROPIC_OUTPUT_LIMITS = {
|
||||||
|
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
|
||||||
|
"claude-fable": 128_000,
|
||||||
# Claude 4.8
|
# Claude 4.8
|
||||||
"claude-opus-4-8": 128_000,
|
"claude-opus-4-8": 128_000,
|
||||||
# Claude 4.7
|
# Claude 4.7
|
||||||
@@ -208,8 +240,17 @@ def _resolve_anthropic_messages_max_tokens(
|
|||||||
|
|
||||||
|
|
||||||
def _supports_adaptive_thinking(model: str) -> bool:
|
def _supports_adaptive_thinking(model: str) -> bool:
|
||||||
"""Return True for Claude 4.6+ models that support adaptive thinking."""
|
"""Return True for Claude models that use adaptive thinking (4.6+).
|
||||||
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
|
|
||||||
|
Defaults *unknown* Claude models to adaptive (the modern contract) and
|
||||||
|
only returns False for the explicit legacy list of older Claude families
|
||||||
|
that require manual budget-based thinking. Non-Claude Anthropic-Messages
|
||||||
|
models (minimax, qwen3, …) return False so they keep the manual path.
|
||||||
|
"""
|
||||||
|
if not _is_claude_model(model):
|
||||||
|
return False
|
||||||
|
m = model.lower()
|
||||||
|
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
|
||||||
|
|
||||||
|
|
||||||
def _supports_xhigh_effort(model: str) -> bool:
|
def _supports_xhigh_effort(model: str) -> bool:
|
||||||
@@ -219,18 +260,33 @@ def _supports_xhigh_effort(model: str) -> bool:
|
|||||||
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
|
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
|
||||||
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
|
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
|
||||||
when this returns False.
|
when this returns False.
|
||||||
|
|
||||||
|
Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
|
||||||
|
only the 4.6 family and legacy manual-thinking models are excluded.
|
||||||
"""
|
"""
|
||||||
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
|
if not _supports_adaptive_thinking(model):
|
||||||
|
return False
|
||||||
|
m = model.lower()
|
||||||
|
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
|
||||||
|
|
||||||
|
|
||||||
def _forbids_sampling_params(model: str) -> bool:
|
def _forbids_sampling_params(model: str) -> bool:
|
||||||
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
|
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
|
||||||
|
|
||||||
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
|
Opus 4.7 introduced this restriction; later Claude releases follow it.
|
||||||
expected to follow suit. Callers should omit these fields entirely rather
|
Defaults unknown Claude models to forbidding sampling params (the modern
|
||||||
than passing zero/default values (the API rejects anything non-null).
|
contract). The 4.6 family still accepts them, and the legacy manual-thinking
|
||||||
|
families (4.5 and older) accept them too, so both are excluded. Non-Claude
|
||||||
|
models are unaffected. Callers should omit these fields entirely rather than
|
||||||
|
passing zero/default values (the API rejects anything non-null).
|
||||||
"""
|
"""
|
||||||
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
|
if not _is_claude_model(model):
|
||||||
|
return False
|
||||||
|
m = model.lower()
|
||||||
|
# 4.6 family is adaptive but still accepts sampling params.
|
||||||
|
if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
|
||||||
|
return False
|
||||||
|
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
|
||||||
|
|
||||||
|
|
||||||
def _supports_fast_mode(model: str) -> bool:
|
def _supports_fast_mode(model: str) -> bool:
|
||||||
@@ -821,6 +877,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
logger.debug("Keychain: security command not available or timed out")
|
logger.debug("Keychain: security command not available or timed out")
|
||||||
@@ -1163,7 +1220,10 @@ def run_oauth_setup_token() -> Optional[str]:
|
|||||||
"Install it with: npm install -g @anthropic-ai/claude-code"
|
"Install it with: npm install -g @anthropic-ai/claude-code"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run interactively — stdin/stdout/stderr inherited so user can interact
|
# Run interactively — stdin/stdout/stderr inherited so the user can
|
||||||
|
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
|
||||||
|
# concern does not apply to an interactive login the user explicitly
|
||||||
|
# invokes. noqa: subprocess-stdin
|
||||||
try:
|
try:
|
||||||
subprocess.run([claude_path, "setup-token"])
|
subprocess.run([claude_path, "setup-token"])
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
@@ -1511,6 +1571,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
|
|||||||
|
|
||||||
if ptype == "input_text":
|
if ptype == "input_text":
|
||||||
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
|
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
|
||||||
|
elif ptype == "text":
|
||||||
|
# A stored Anthropic text block. Rebuild from whitelisted fields only —
|
||||||
|
# SDK response text blocks carry output-only siblings (parsed_output,
|
||||||
|
# citations=None) that the Messages INPUT schema rejects with HTTP 400
|
||||||
|
# "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
|
||||||
|
block = {"type": "text", "text": part.get("text", "")}
|
||||||
|
cits = part.get("citations")
|
||||||
|
if isinstance(cits, list) and cits:
|
||||||
|
block["citations"] = cits
|
||||||
elif ptype in {"image_url", "input_image"}:
|
elif ptype in {"image_url", "input_image"}:
|
||||||
image_value = part.get("image_url", {})
|
image_value = part.get("image_url", {})
|
||||||
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
|
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
|
||||||
@@ -1625,6 +1694,58 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Strip output-only fields from a stored Anthropic content block so it is
|
||||||
|
valid as REQUEST input on replay.
|
||||||
|
|
||||||
|
The SDK response objects carry output-only attributes that the Messages
|
||||||
|
*input* schema forbids ("Extra inputs are not permitted"): text blocks get
|
||||||
|
``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
|
||||||
|
etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
|
||||||
|
so these leak back as input on the next turn → HTTP 400.
|
||||||
|
|
||||||
|
Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
|
||||||
|
reintroduce the bug. Returns a clean block, or None to drop it.
|
||||||
|
"""
|
||||||
|
if not isinstance(b, dict):
|
||||||
|
return None
|
||||||
|
btype = b.get("type")
|
||||||
|
if btype == "text":
|
||||||
|
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
|
||||||
|
# citations is input-valid ONLY when it's a non-empty list; the SDK
|
||||||
|
# emits citations=None on responses, which the input schema rejects.
|
||||||
|
cits = b.get("citations")
|
||||||
|
if isinstance(cits, list) and cits:
|
||||||
|
out["citations"] = cits
|
||||||
|
if isinstance(b.get("cache_control"), dict):
|
||||||
|
out["cache_control"] = b["cache_control"]
|
||||||
|
return out
|
||||||
|
if btype == "thinking":
|
||||||
|
out = {"type": "thinking", "thinking": b.get("thinking", "")}
|
||||||
|
if b.get("signature"):
|
||||||
|
out["signature"] = b["signature"]
|
||||||
|
return out
|
||||||
|
if btype == "redacted_thinking":
|
||||||
|
# Only valid with its data payload; drop if missing.
|
||||||
|
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
|
||||||
|
if btype == "tool_use":
|
||||||
|
out = {
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": _sanitize_tool_id(b.get("id", "")),
|
||||||
|
"name": b.get("name", ""),
|
||||||
|
"input": b.get("input", {}),
|
||||||
|
}
|
||||||
|
if isinstance(b.get("cache_control"), dict):
|
||||||
|
out["cache_control"] = b["cache_control"]
|
||||||
|
return out
|
||||||
|
if btype == "image":
|
||||||
|
src = b.get("source")
|
||||||
|
return {"type": "image", "source": src} if isinstance(src, dict) else None
|
||||||
|
# Unknown/unsupported block type on the input path — drop rather than risk
|
||||||
|
# another "Extra inputs are not permitted".
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Convert an assistant message to Anthropic content blocks.
|
"""Convert an assistant message to Anthropic content blocks.
|
||||||
|
|
||||||
@@ -1632,6 +1753,55 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
reasoning_content injection for Kimi/DeepSeek endpoints.
|
reasoning_content injection for Kimi/DeepSeek endpoints.
|
||||||
"""
|
"""
|
||||||
content = m.get("content", "")
|
content = m.get("content", "")
|
||||||
|
# Anthropic interleaved-thinking fast path: when this turn carries a
|
||||||
|
# verbatim, order-preserving block list (set by normalize_response only
|
||||||
|
# for turns that interleave SIGNED thinking with tool_use), replay it.
|
||||||
|
# Each block is run through _sanitize_replay_block to strip output-only
|
||||||
|
# SDK fields (parsed_output, caller, citations=None, …) that the Messages
|
||||||
|
# INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra
|
||||||
|
# inputs are not permitted" (text.parsed_output). Block ORDER is preserved
|
||||||
|
# (the reason this channel exists); only forbidden sibling fields are
|
||||||
|
# dropped, leaving thinking signatures and tool_use id/name/input intact.
|
||||||
|
ordered_blocks = m.get("anthropic_content_blocks")
|
||||||
|
if isinstance(ordered_blocks, list) and ordered_blocks:
|
||||||
|
# Re-source each tool_use input from the stored tool_calls map rather
|
||||||
|
# than the captured block. The ordered-blocks list captures tool_use
|
||||||
|
# input from the RAW API response (normalize_response), which is NOT
|
||||||
|
# credential-redacted; tool_calls[].function.arguments IS redacted at
|
||||||
|
# storage time (build_assistant_message, #19798). Replaying the raw
|
||||||
|
# block input would resurrect a secret the model inlined into a tool
|
||||||
|
# call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'")
|
||||||
|
# onto the wire, even though the same value is redacted everywhere else
|
||||||
|
# in history. Keying by sanitized tool id preserves interleave order
|
||||||
|
# (the reason this channel exists) while swapping in the redacted
|
||||||
|
# input. Adapted from #36071 (replay-time tool-input re-sourcing).
|
||||||
|
redacted_input_by_id: Dict[str, Any] = {}
|
||||||
|
for tc in m.get("tool_calls", []) or []:
|
||||||
|
if not isinstance(tc, dict):
|
||||||
|
continue
|
||||||
|
fn = tc.get("function", {}) or {}
|
||||||
|
raw_args = fn.get("arguments", "{}")
|
||||||
|
try:
|
||||||
|
parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
parsed_args = {}
|
||||||
|
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
|
||||||
|
replayed: List[Dict[str, Any]] = []
|
||||||
|
for b in ordered_blocks:
|
||||||
|
clean = _sanitize_replay_block(b)
|
||||||
|
if clean is None:
|
||||||
|
continue
|
||||||
|
if clean.get("type") == "tool_use":
|
||||||
|
# Override raw (un-redacted) input with the redacted copy when
|
||||||
|
# we have one for this id; fall back to the sanitized block
|
||||||
|
# input only if the tool_call is missing (shape mismatch).
|
||||||
|
redacted = redacted_input_by_id.get(clean.get("id", ""))
|
||||||
|
if redacted is not None:
|
||||||
|
clean["input"] = redacted
|
||||||
|
replayed.append(clean)
|
||||||
|
if replayed:
|
||||||
|
return {"role": "assistant", "content": replayed}
|
||||||
|
|
||||||
blocks = _extract_preserved_thinking_blocks(m)
|
blocks = _extract_preserved_thinking_blocks(m)
|
||||||
if content:
|
if content:
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
|||||||
from agent.credential_pool import load_pool
|
from agent.credential_pool import load_pool
|
||||||
from hermes_cli.config import get_hermes_home
|
from hermes_cli.config import get_hermes_home
|
||||||
from hermes_constants import OPENROUTER_BASE_URL
|
from hermes_constants import OPENROUTER_BASE_URL
|
||||||
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
|
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
|
|||||||
return _nous_extra_body() if auxiliary_is_nous else {}
|
return _nous_extra_body() if auxiliary_is_nous else {}
|
||||||
|
|
||||||
|
|
||||||
def auxiliary_max_tokens_param(value: int) -> dict:
|
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
|
||||||
"""Return the correct max tokens kwarg for the auxiliary client's provider.
|
"""Return the correct max tokens kwarg for the auxiliary client's provider.
|
||||||
|
|
||||||
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
|
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
|
||||||
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
|
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
|
||||||
The Codex adapter translates max_tokens internally, so we use max_tokens
|
The Codex adapter translates max_tokens internally, so we use max_tokens
|
||||||
for it as well.
|
for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
|
||||||
|
fronting the newer families are also recognised — URL-only detection
|
||||||
|
misses the case where a custom base URL serves e.g. ``gpt-5.4``.
|
||||||
"""
|
"""
|
||||||
custom_base = _current_custom_base_url()
|
custom_base = _current_custom_base_url()
|
||||||
or_key = os.getenv("OPENROUTER_API_KEY")
|
or_key = os.getenv("OPENROUTER_API_KEY")
|
||||||
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
|
|||||||
and _read_nous_auth() is None
|
and _read_nous_auth() is None
|
||||||
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
|
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
|
||||||
return {"max_completion_tokens": value}
|
return {"max_completion_tokens": value}
|
||||||
|
# ...and for any caller serving a newer OpenAI-family model by name.
|
||||||
|
if model_forces_max_completion_tokens(model):
|
||||||
|
return {"max_completion_tokens": value}
|
||||||
return {"max_tokens": value}
|
return {"max_tokens": value}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -952,6 +952,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
|||||||
if preserved:
|
if preserved:
|
||||||
msg["reasoning_details"] = preserved
|
msg["reasoning_details"] = preserved
|
||||||
|
|
||||||
|
# Anthropic interleaved-thinking replay: when a turn interleaves signed
|
||||||
|
# thinking blocks with tool_use, the parallel reasoning_details +
|
||||||
|
# tool_calls fields lose the cross-type ordering, and reconstruction
|
||||||
|
# front-loads thinking — reordering signed blocks and triggering HTTP 400
|
||||||
|
# ("thinking ... blocks in the latest assistant message cannot be
|
||||||
|
# modified"). Carry the verbatim ordered block list so the adapter can
|
||||||
|
# replay the latest assistant message unchanged. See
|
||||||
|
# agent/transports/anthropic.py and agent/anthropic_adapter.py.
|
||||||
|
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
|
||||||
|
if ordered_blocks:
|
||||||
|
msg["anthropic_content_blocks"] = ordered_blocks
|
||||||
|
|
||||||
# Codex Responses API: preserve encrypted reasoning items for
|
# Codex Responses API: preserve encrypted reasoning items for
|
||||||
# multi-turn continuity. These get replayed as input on the next turn.
|
# multi-turn continuity. These get replayed as input on the next turn.
|
||||||
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
|
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
|
||||||
@@ -1698,6 +1710,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||||||
# poll loop uses this to detect stale connections that keep receiving
|
# poll loop uses this to detect stale connections that keep receiving
|
||||||
# SSE keep-alive pings but no actual data.
|
# SSE keep-alive pings but no actual data.
|
||||||
last_chunk_time = {"t": time.time()}
|
last_chunk_time = {"t": time.time()}
|
||||||
|
# Stale-stream patience, shared between the httpx socket read timeout
|
||||||
|
# (built in ``_call_chat_completions`` below) and the stale-stream detector
|
||||||
|
# (computed further down, before the worker thread starts). Initialized
|
||||||
|
# here so the read-timeout builder can floor itself at the stale value and
|
||||||
|
# never fire before the detector. ``None`` until the detector value is
|
||||||
|
# resolved, so the builder degrades to its plain default if it ever runs
|
||||||
|
# first.
|
||||||
|
_stream_stale_timeout = None
|
||||||
|
|
||||||
def _fire_first_delta():
|
def _fire_first_delta():
|
||||||
if not first_delta_fired["done"] and on_first_delta:
|
if not first_delta_fired["done"] and on_first_delta:
|
||||||
@@ -1734,6 +1754,26 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||||||
"Local provider detected (%s) — stream read timeout raised to %.0fs",
|
"Local provider detected (%s) — stream read timeout raised to %.0fs",
|
||||||
agent.base_url, _stream_read_timeout,
|
agent.base_url, _stream_read_timeout,
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
_stream_read_timeout == 120.0
|
||||||
|
and _stream_stale_timeout is not None
|
||||||
|
and _stream_stale_timeout != float("inf")
|
||||||
|
and _stream_stale_timeout > _stream_read_timeout
|
||||||
|
):
|
||||||
|
# Cloud reasoning models (e.g. Opus) routinely pause mid-stream
|
||||||
|
# for minutes during extended thinking. The stale-stream
|
||||||
|
# detector is deliberately scaled up to tolerate this (180–300s,
|
||||||
|
# see the stale-timeout block below), but the raw httpx socket
|
||||||
|
# read timeout defaulted to a flat 120s and fired *first* —
|
||||||
|
# tearing down a healthy reasoning stream before the stale
|
||||||
|
# detector (which owns retry + diagnostics) could act. Keep the
|
||||||
|
# socket read timeout in step with the detector so it no longer
|
||||||
|
# preempts it.
|
||||||
|
_stream_read_timeout = _stream_stale_timeout
|
||||||
|
logger.debug(
|
||||||
|
"Cloud reasoning stream — read timeout raised to %.0fs to "
|
||||||
|
"match stale-stream detector", _stream_read_timeout,
|
||||||
|
)
|
||||||
# Cap connect/pool at 60s even when provider timeout is higher.
|
# Cap connect/pool at 60s even when provider timeout is higher.
|
||||||
# connect/pool cover TCP handshake, not model inference.
|
# connect/pool cover TCP handshake, not model inference.
|
||||||
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
|
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
|
||||||
|
|||||||
@@ -25,6 +25,154 @@ from typing import Any, Dict, List
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_usage_int(value: Any) -> int:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return 0
|
||||||
|
if isinstance(value, int):
|
||||||
|
return max(value, 0)
|
||||||
|
if isinstance(value, float):
|
||||||
|
return max(int(value), 0)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return max(int(value), 0)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
||||||
|
"""Translate Codex app-server token usage into Hermes accounting.
|
||||||
|
|
||||||
|
Codex app-server reports usage via thread/tokenUsage/updated as:
|
||||||
|
inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens,
|
||||||
|
totalTokens.
|
||||||
|
|
||||||
|
Hermes' canonical prompt bucket includes uncached input + cached input.
|
||||||
|
The Codex app-server protocol does not currently expose cache-write tokens,
|
||||||
|
so that bucket remains zero on this runtime.
|
||||||
|
|
||||||
|
Even when Codex omits usage for a turn, Hermes should still count that turn
|
||||||
|
as one API call for session/status accounting.
|
||||||
|
"""
|
||||||
|
agent.session_api_calls += 1
|
||||||
|
|
||||||
|
usage = getattr(turn, "token_usage_last", None)
|
||||||
|
if not isinstance(usage, dict) or not usage:
|
||||||
|
if agent._session_db and agent.session_id:
|
||||||
|
try:
|
||||||
|
if not agent._session_db_created:
|
||||||
|
agent._ensure_db_session()
|
||||||
|
agent._session_db.update_token_counts(
|
||||||
|
agent.session_id,
|
||||||
|
model=agent.model,
|
||||||
|
api_call_count=1,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Codex app-server api-call persistence failed (session=%s): %s",
|
||||||
|
agent.session_id, exc,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
|
||||||
|
|
||||||
|
input_tokens = _coerce_usage_int(usage.get("inputTokens"))
|
||||||
|
cache_read_tokens = _coerce_usage_int(usage.get("cachedInputTokens"))
|
||||||
|
output_tokens = _coerce_usage_int(usage.get("outputTokens"))
|
||||||
|
reasoning_tokens = _coerce_usage_int(usage.get("reasoningOutputTokens"))
|
||||||
|
reported_total = _coerce_usage_int(usage.get("totalTokens"))
|
||||||
|
|
||||||
|
canonical_usage = CanonicalUsage(
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
cache_read_tokens=cache_read_tokens,
|
||||||
|
cache_write_tokens=0,
|
||||||
|
reasoning_tokens=reasoning_tokens,
|
||||||
|
raw_usage=usage,
|
||||||
|
)
|
||||||
|
prompt_tokens = canonical_usage.prompt_tokens
|
||||||
|
completion_tokens = canonical_usage.output_tokens
|
||||||
|
total_tokens = reported_total or canonical_usage.total_tokens
|
||||||
|
usage_dict = {
|
||||||
|
"prompt_tokens": prompt_tokens,
|
||||||
|
"completion_tokens": completion_tokens,
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
"input_tokens": canonical_usage.input_tokens,
|
||||||
|
"output_tokens": canonical_usage.output_tokens,
|
||||||
|
"cache_read_tokens": canonical_usage.cache_read_tokens,
|
||||||
|
"cache_write_tokens": canonical_usage.cache_write_tokens,
|
||||||
|
"reasoning_tokens": canonical_usage.reasoning_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
compressor = getattr(agent, "context_compressor", None)
|
||||||
|
if compressor is not None:
|
||||||
|
try:
|
||||||
|
compressor.update_from_response(usage_dict)
|
||||||
|
context_window = getattr(turn, "model_context_window", None)
|
||||||
|
if isinstance(context_window, int) and context_window > 0:
|
||||||
|
compressor.context_length = context_window
|
||||||
|
except Exception:
|
||||||
|
logger.debug("codex app-server usage update failed", exc_info=True)
|
||||||
|
|
||||||
|
agent.session_prompt_tokens += prompt_tokens
|
||||||
|
agent.session_completion_tokens += completion_tokens
|
||||||
|
agent.session_total_tokens += total_tokens
|
||||||
|
agent.session_input_tokens += canonical_usage.input_tokens
|
||||||
|
agent.session_output_tokens += canonical_usage.output_tokens
|
||||||
|
agent.session_cache_read_tokens += canonical_usage.cache_read_tokens
|
||||||
|
agent.session_cache_write_tokens += canonical_usage.cache_write_tokens
|
||||||
|
agent.session_reasoning_tokens += canonical_usage.reasoning_tokens
|
||||||
|
|
||||||
|
cost_result = estimate_usage_cost(
|
||||||
|
agent.model,
|
||||||
|
canonical_usage,
|
||||||
|
provider=agent.provider,
|
||||||
|
base_url=agent.base_url,
|
||||||
|
api_key=getattr(agent, "api_key", ""),
|
||||||
|
)
|
||||||
|
if cost_result.amount_usd is not None:
|
||||||
|
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
|
||||||
|
agent.session_cost_status = cost_result.status
|
||||||
|
agent.session_cost_source = cost_result.source
|
||||||
|
|
||||||
|
if agent._session_db and agent.session_id:
|
||||||
|
try:
|
||||||
|
if not agent._session_db_created:
|
||||||
|
agent._ensure_db_session()
|
||||||
|
agent._session_db.update_token_counts(
|
||||||
|
agent.session_id,
|
||||||
|
input_tokens=canonical_usage.input_tokens,
|
||||||
|
output_tokens=canonical_usage.output_tokens,
|
||||||
|
cache_read_tokens=canonical_usage.cache_read_tokens,
|
||||||
|
cache_write_tokens=canonical_usage.cache_write_tokens,
|
||||||
|
reasoning_tokens=canonical_usage.reasoning_tokens,
|
||||||
|
estimated_cost_usd=float(cost_result.amount_usd)
|
||||||
|
if cost_result.amount_usd is not None else None,
|
||||||
|
cost_status=cost_result.status,
|
||||||
|
cost_source=cost_result.source,
|
||||||
|
billing_provider=agent.provider,
|
||||||
|
billing_base_url=agent.base_url,
|
||||||
|
billing_mode="subscription_included"
|
||||||
|
if cost_result.status == "included" else None,
|
||||||
|
model=agent.model,
|
||||||
|
api_call_count=1,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Codex app-server token persistence failed (session=%s, tokens=%d): %s",
|
||||||
|
agent.session_id, total_tokens, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**usage_dict,
|
||||||
|
"last_prompt_tokens": prompt_tokens,
|
||||||
|
"estimated_cost_usd": float(cost_result.amount_usd)
|
||||||
|
if cost_result.amount_usd is not None else None,
|
||||||
|
"cost_status": cost_result.status,
|
||||||
|
"cost_source": cost_result.source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_codex_app_server_turn(
|
def run_codex_app_server_turn(
|
||||||
agent,
|
agent,
|
||||||
*,
|
*,
|
||||||
@@ -120,6 +268,8 @@ def run_codex_app_server_turn(
|
|||||||
agent._iters_since_skill = (
|
agent._iters_since_skill = (
|
||||||
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
|
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
|
||||||
)
|
)
|
||||||
|
usage_result = _record_codex_app_server_usage(agent, turn)
|
||||||
|
api_calls = 1
|
||||||
|
|
||||||
# Now check the skill nudge AFTER iters were incremented — same
|
# Now check the skill nudge AFTER iters were incremented — same
|
||||||
# pattern the chat_completions path uses (line ~15432).
|
# pattern the chat_completions path uses (line ~15432).
|
||||||
@@ -164,12 +314,13 @@ def run_codex_app_server_turn(
|
|||||||
return {
|
return {
|
||||||
"final_response": turn.final_text,
|
"final_response": turn.final_text,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"api_calls": 1, # one app-server "turn" maps to one logical API call
|
"api_calls": api_calls,
|
||||||
"completed": not turn.interrupted and turn.error is None,
|
"completed": not turn.interrupted and turn.error is None,
|
||||||
"partial": turn.interrupted or turn.error is not None,
|
"partial": turn.interrupted or turn.error is not None,
|
||||||
"error": turn.error,
|
"error": turn.error,
|
||||||
"codex_thread_id": turn.thread_id,
|
"codex_thread_id": turn.thread_id,
|
||||||
"codex_turn_id": turn.turn_id,
|
"codex_turn_id": turn.turn_id,
|
||||||
|
**usage_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
700
agent/coding_context.py
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
"""Coding-context awareness — base Hermes, every interactive surface.
|
||||||
|
|
||||||
|
When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
|
||||||
|
editor over ACP), Hermes shifts into a **coding posture**. This module is the
|
||||||
|
single place that decides whether we're in that posture and what it implies,
|
||||||
|
so the rest of the codebase never re-derives "are we coding?" on its own.
|
||||||
|
|
||||||
|
Architecture — one seam, many consumers
|
||||||
|
----------------------------------------
|
||||||
|
The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
|
||||||
|
:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
|
||||||
|
is *data* — it declares the toolset to collapse to, the operating brief to
|
||||||
|
inject, and hints for other domains (model routing, memory, subagents). Every
|
||||||
|
domain reads the same resolved object instead of probing git/config itself:
|
||||||
|
|
||||||
|
* **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief +
|
||||||
|
a live git/workspace snapshot (``agent/system_prompt.py``).
|
||||||
|
* **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset
|
||||||
|
plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
|
||||||
|
under the opt-in ``focus`` mode: the default posture is prompt-only and
|
||||||
|
never touches the user's configured toolsets (toolsets like messaging /
|
||||||
|
smart-home / music are off-by-default anyway, and someone who explicitly
|
||||||
|
enabled image-gen or Spotify shouldn't lose it for being in a git repo).
|
||||||
|
* **Delegation** — subagents inherit the parent's toolset and run through the
|
||||||
|
same prompt builder, so the coding posture propagates to children for free.
|
||||||
|
* **Model / memory / compression** — declared on the profile
|
||||||
|
(``model_hint``, ``memory_policy``) as the extension seam; consumers read
|
||||||
|
``mode.profile`` rather than re-deciding.
|
||||||
|
|
||||||
|
Cache safety
|
||||||
|
------------
|
||||||
|
The mode is resolved **once** and is immutable. The workspace snapshot is built
|
||||||
|
once at prompt-build time and baked into the *stable* system-prompt tier — never
|
||||||
|
re-probed per turn (that would shatter the prompt cache). Branch and dirty state
|
||||||
|
drift mid-session, so the brief tells the model to re-check with ``git`` before
|
||||||
|
acting on the snapshot. A ``/coding`` flip therefore only takes effect next
|
||||||
|
session (deferred), the same contract as ``/skills install`` vs ``--now``.
|
||||||
|
|
||||||
|
Activation (config ``agent.coding_context``):
|
||||||
|
|
||||||
|
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
|
||||||
|
surface sitting in a code workspace (git repo or recognised project root).
|
||||||
|
Prompt-only; toolsets untouched.
|
||||||
|
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
|
||||||
|
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
|
||||||
|
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
|
||||||
|
* ``off`` — disable entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.coding_context")
|
||||||
|
|
||||||
|
CODING_TOOLSET = "coding"
|
||||||
|
|
||||||
|
# Surfaces where a coding posture makes sense under ``auto``. Messaging
|
||||||
|
# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot
|
||||||
|
# in a group is not pair-programming.
|
||||||
|
INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
|
||||||
|
|
||||||
|
# Project-root signals that mark a directory as a code workspace even when it
|
||||||
|
# isn't (yet) a git repo. Cheap filename checks — no parsing.
|
||||||
|
_PROJECT_MARKERS = (
|
||||||
|
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
|
||||||
|
"package.json", "tsconfig.json", "deno.json",
|
||||||
|
"Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
|
||||||
|
"Gemfile", "composer.json", "mix.exs", "pubspec.yaml",
|
||||||
|
"CMakeLists.txt", "Makefile", "Dockerfile",
|
||||||
|
"AGENTS.md", "CLAUDE.md", ".cursorrules",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agent-instruction files surfaced separately from manifests in the snapshot.
|
||||||
|
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
|
||||||
|
|
||||||
|
# Lockfile → package manager, checked in priority order.
|
||||||
|
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
|
||||||
|
_JS_LOCKFILES = (
|
||||||
|
("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
|
||||||
|
("yarn.lock", "yarn"), ("package-lock.json", "npm"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# package.json scripts / Makefile targets worth surfacing as verify commands.
|
||||||
|
_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
|
||||||
|
_MAX_VERIFY_COMMANDS = 8
|
||||||
|
_MAX_FACT_FILE_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
_GIT_TIMEOUT = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
# Per-model edit-format steering. Matching the edit tool format to how a model
|
||||||
|
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
|
||||||
|
# patch-style diffs best; Anthropic models — and most open-weight coding
|
||||||
|
# models, whose RL scaffolds use str_replace-style editors — do best with
|
||||||
|
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
|
||||||
|
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
|
||||||
|
# its native format. Unknown families get nothing (the brief's neutral wording
|
||||||
|
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
|
||||||
|
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
|
||||||
|
"patch": (
|
||||||
|
("gpt", "codex"),
|
||||||
|
"- Edit format: author new files with `write_file`; for edits to "
|
||||||
|
"existing code prefer `patch` with `mode='patch'` (V4A multi-file diff) "
|
||||||
|
"for structured or multi-file changes — it's the diff format you handle "
|
||||||
|
"most reliably. Use `mode='replace'` for a single small swap.",
|
||||||
|
),
|
||||||
|
"replace": (
|
||||||
|
("claude", "sonnet", "opus", "haiku",
|
||||||
|
"gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
|
||||||
|
"hermes", "llama", "mistral", "devstral", "minimax"),
|
||||||
|
"- Edit format: author new files with `write_file`; for edits to "
|
||||||
|
"existing code prefer `patch` in `mode='replace'` — match a unique "
|
||||||
|
"snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
|
||||||
|
"genuinely spans several files at once.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _model_family(model: Optional[str]) -> Optional[str]:
|
||||||
|
"""Classify a model id into an edit-format family key, or ``None``.
|
||||||
|
|
||||||
|
Used to steer the coding posture toward the edit tool format a model was
|
||||||
|
trained on. Family-agnostic by design: an unrecognised model gets ``None``
|
||||||
|
and the operating brief's neutral edit wording applies.
|
||||||
|
"""
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
lowered = model.lower()
|
||||||
|
for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
|
||||||
|
if any(n in lowered for n in needles):
|
||||||
|
return family
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_format_line(model: Optional[str]) -> str:
|
||||||
|
"""The edit-format guidance line for this model's family (``""`` if none)."""
|
||||||
|
family = _model_family(model)
|
||||||
|
if family is None:
|
||||||
|
return ""
|
||||||
|
return _EDIT_FORMAT_GUIDANCE[family][1]
|
||||||
|
|
||||||
|
|
||||||
|
# Operating brief for the coding posture. Tool names referenced here (read_file,
|
||||||
|
# search_files, patch, write_file, terminal, todo) are in the coding toolset and
|
||||||
|
# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on.
|
||||||
|
CODING_AGENT_GUIDANCE = (
|
||||||
|
"You are a coding agent pairing with the user inside their codebase. "
|
||||||
|
"Operate like a careful senior engineer.\n"
|
||||||
|
"\n"
|
||||||
|
"Gather context first:\n"
|
||||||
|
"- Read the relevant files with `read_file` and locate code with "
|
||||||
|
"`search_files` before changing anything. Trace a symbol to its definition "
|
||||||
|
"and usages rather than guessing its shape.\n"
|
||||||
|
"- Batch independent lookups: when several reads/searches don't depend on "
|
||||||
|
"each other, issue them together in one turn instead of one at a time.\n"
|
||||||
|
"- Never invent files, symbols, APIs, or imports. If you haven't seen it in "
|
||||||
|
"the repo, go look. Don't assume a library is available — check the project "
|
||||||
|
"manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how "
|
||||||
|
"neighbouring files import it.\n"
|
||||||
|
"\n"
|
||||||
|
"Make changes through the tools, not the chat:\n"
|
||||||
|
"- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as "
|
||||||
|
"a substitute for editing — apply the change, then summarise it. Only show "
|
||||||
|
"code when the user explicitly asks to see it.\n"
|
||||||
|
"- Match the project's existing style and conventions; AGENTS.md / "
|
||||||
|
"CLAUDE.md / .cursorrules already in context win over your defaults. Touch "
|
||||||
|
"only what the task needs — no drive-by refactors, renames, or reformatting "
|
||||||
|
"— and add any imports/dependencies your code requires.\n"
|
||||||
|
"- If an edit fails to apply, re-read the file to get the current exact "
|
||||||
|
"contents before retrying — don't repeat a stale patch. If the same region "
|
||||||
|
"fails twice, rewrite the enclosing function or file with `write_file` "
|
||||||
|
"instead of attempting a third patch.\n"
|
||||||
|
"\n"
|
||||||
|
"Verify, and know when to stop:\n"
|
||||||
|
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
|
||||||
|
"tests/linter/build and confirm they pass before claiming the work is done.\n"
|
||||||
|
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
|
||||||
|
"paths for the same flaw and fix the class, not just the reported site.\n"
|
||||||
|
"- When fixing linter/type errors on a file, stop after about three "
|
||||||
|
"attempts on the same file and ask the user rather than looping.\n"
|
||||||
|
"- Track multi-step work with `todo`. Reference code as `path:line` instead "
|
||||||
|
"of pasting whole files.\n"
|
||||||
|
"\n"
|
||||||
|
"Respect the user's repo: don't commit, push, or rewrite history unless "
|
||||||
|
"asked, and never read, print, or commit secrets — leave `.env` and "
|
||||||
|
"credential files alone unless the user explicitly asks. The Workspace "
|
||||||
|
"block below is a snapshot from session start — re-run `git status`/"
|
||||||
|
"`git branch` before relying on it. Be concise: lead with the change or "
|
||||||
|
"answer, not a preamble."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Context profiles (declarative posture definitions) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContextProfile:
|
||||||
|
"""A named operating posture. Pure data — consumers read these fields.
|
||||||
|
|
||||||
|
``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit
|
||||||
|
selection is pinned; ``None`` keeps the platform default.
|
||||||
|
``guidance`` — operating brief injected into the stable system prompt;
|
||||||
|
``""`` injects nothing.
|
||||||
|
``model_hint`` — routing preference key for smart model routing
|
||||||
|
(extension seam; not yet consumed by the router).
|
||||||
|
``memory_policy``— memory namespace/weighting hint (extension seam).
|
||||||
|
``hidden_skill_categories`` — skill categories pruned from the system-prompt
|
||||||
|
skill index while this posture is active. Discovery-only:
|
||||||
|
nothing is disabled — ``skills_list`` still returns the
|
||||||
|
full catalog and ``skill_view`` loads anything. Deny-list
|
||||||
|
semantics so unknown/custom categories stay visible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
toolset: Optional[str] = None
|
||||||
|
guidance: str = ""
|
||||||
|
model_hint: Optional[str] = None
|
||||||
|
memory_policy: str = "default"
|
||||||
|
hidden_skill_categories: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
# Skill categories that are clearly not part of a coding workflow. Hidden from
|
||||||
|
# the prompt's skill index in the coding posture (deny-list — anything not
|
||||||
|
# listed here, incl. custom user categories, stays visible). Coding-adjacent
|
||||||
|
# categories (devops, github, mcp, data-science, diagramming, research,
|
||||||
|
# security, …) are intentionally absent.
|
||||||
|
_NON_CODING_SKILL_CATEGORIES = (
|
||||||
|
"apple", "communication", "cooking", "creative", "email", "finance",
|
||||||
|
"gaming", "gifs", "health", "media", "music", "note-taking",
|
||||||
|
"productivity", "shopping", "smart-home", "social-media", "travel",
|
||||||
|
"yuanbao",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
GENERAL_PROFILE = ContextProfile(name="general")
|
||||||
|
CODING_PROFILE = ContextProfile(
|
||||||
|
name="coding",
|
||||||
|
toolset=CODING_TOOLSET,
|
||||||
|
guidance=CODING_AGENT_GUIDANCE,
|
||||||
|
model_hint="coding",
|
||||||
|
memory_policy="project",
|
||||||
|
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROFILES: dict[str, ContextProfile] = {
|
||||||
|
GENERAL_PROFILE.name: GENERAL_PROFILE,
|
||||||
|
CODING_PROFILE.name: CODING_PROFILE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile(name: str) -> ContextProfile:
|
||||||
|
"""Return a registered profile, falling back to ``general``."""
|
||||||
|
return _PROFILES.get(name, GENERAL_PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _coding_mode(config: Optional[dict[str, Any]]) -> str:
|
||||||
|
"""Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
|
||||||
|
if config is None:
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
except Exception:
|
||||||
|
config = {}
|
||||||
|
raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
|
||||||
|
mode = str(raw).strip().lower()
|
||||||
|
if mode in {"focus", "strict", "lean"}:
|
||||||
|
return "focus"
|
||||||
|
if mode in {"on", "true", "yes", "1", "always"}:
|
||||||
|
return "on"
|
||||||
|
if mode in {"off", "false", "no", "0", "never"}:
|
||||||
|
return "off"
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
|
||||||
|
if cwd:
|
||||||
|
return Path(cwd).expanduser()
|
||||||
|
try:
|
||||||
|
from agent.runtime_cwd import resolve_agent_cwd
|
||||||
|
|
||||||
|
return resolve_agent_cwd()
|
||||||
|
except Exception:
|
||||||
|
return Path(os.getcwd())
|
||||||
|
|
||||||
|
|
||||||
|
def _git_root(cwd: Path) -> Optional[Path]:
|
||||||
|
current = cwd.resolve()
|
||||||
|
for parent in [current, *current.parents]:
|
||||||
|
if (parent / ".git").exists():
|
||||||
|
return parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _home() -> Optional[Path]:
|
||||||
|
try:
|
||||||
|
return Path.home().resolve()
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_root(cwd: Path) -> Optional[Path]:
|
||||||
|
"""Nearest ancestor that looks like a project root, or ``None``.
|
||||||
|
|
||||||
|
Walks up at most a few levels so a manifest in the workspace root counts
|
||||||
|
even when the user is in a subdirectory. ``$HOME`` itself is skipped — a
|
||||||
|
Makefile or AGENTS.md sitting in the home directory is global user config,
|
||||||
|
not a project-root signal.
|
||||||
|
"""
|
||||||
|
current = cwd.resolve()
|
||||||
|
home = _home()
|
||||||
|
for depth, parent in enumerate([current, *current.parents]):
|
||||||
|
if depth > 6:
|
||||||
|
break
|
||||||
|
if parent == home:
|
||||||
|
continue
|
||||||
|
for marker in _PROJECT_MARKERS:
|
||||||
|
if (parent / marker).exists():
|
||||||
|
return parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
|
||||||
|
"""Resolve which profile applies.
|
||||||
|
|
||||||
|
``auto``/``focus``: coding when the surface is interactive AND the cwd is a
|
||||||
|
code workspace (a git repo or a recognised project root). ``on``: always
|
||||||
|
coding. ``off``: always general.
|
||||||
|
|
||||||
|
A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace
|
||||||
|
signal — without the guard, every session anywhere under a dotfiles-managed
|
||||||
|
home directory would silently flip to the coding posture.
|
||||||
|
|
||||||
|
Detection is intentionally not memoized: it's a handful of ``stat`` calls,
|
||||||
|
and callers resolve the mode once per session anyway. Caching here would
|
||||||
|
risk a stale posture if a long-lived process (gateway/TUI) serves sessions
|
||||||
|
from different working directories.
|
||||||
|
"""
|
||||||
|
if mode == "off":
|
||||||
|
return GENERAL_PROFILE.name
|
||||||
|
if mode == "on":
|
||||||
|
return CODING_PROFILE.name
|
||||||
|
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
|
||||||
|
return GENERAL_PROFILE.name
|
||||||
|
cwd = Path(cwd_str)
|
||||||
|
git_root = _git_root(cwd)
|
||||||
|
if git_root is not None and git_root == _home():
|
||||||
|
git_root = None # dotfiles repo at $HOME — not a code workspace
|
||||||
|
if git_root is not None or _marker_root(cwd) is not None:
|
||||||
|
return CODING_PROFILE.name
|
||||||
|
return GENERAL_PROFILE.name
|
||||||
|
|
||||||
|
|
||||||
|
# ── RuntimeMode (the seam) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeMode:
|
||||||
|
"""The resolved operating posture for a session. Immutable by construction.
|
||||||
|
|
||||||
|
Built once via :func:`resolve_runtime_mode` and consumed by every domain
|
||||||
|
that cares about the coding/general distinction. Never mutate or re-resolve
|
||||||
|
mid-session — that would break the prompt cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
profile: ContextProfile
|
||||||
|
surface: str
|
||||||
|
cwd: Path
|
||||||
|
# The normalized ``agent.coding_context`` mode this posture was resolved
|
||||||
|
# under (auto/focus/on/off). Toolset collapse is gated on ``focus``.
|
||||||
|
config_mode: str = "auto"
|
||||||
|
# The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
|
||||||
|
# only to steer edit-format guidance toward the model's family — see
|
||||||
|
# ``_edit_format_line``. Fixed for the session, so cache-safe.
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kind(self) -> str:
|
||||||
|
return self.profile.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_coding(self) -> bool:
|
||||||
|
return self.profile.name == CODING_PROFILE.name
|
||||||
|
|
||||||
|
def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]:
|
||||||
|
"""Toolset list for this posture, or ``None`` to keep the platform default.
|
||||||
|
|
||||||
|
Non-``None`` only under the opt-in ``focus`` mode. The default posture
|
||||||
|
is prompt-only: most strippable toolsets are off-by-default anyway, and
|
||||||
|
a user who explicitly enabled one (image-gen for frontend/game assets,
|
||||||
|
messaging for build notifications, …) keeps it while coding.
|
||||||
|
|
||||||
|
Callers apply this only when the user hasn't pinned an explicit
|
||||||
|
selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never
|
||||||
|
override a pin. Returns the profile's toolset plus enabled MCP servers.
|
||||||
|
"""
|
||||||
|
if self.config_mode != "focus":
|
||||||
|
return None
|
||||||
|
if self.profile.toolset is None:
|
||||||
|
return None
|
||||||
|
return [self.profile.toolset, *_enabled_mcp_servers(config)]
|
||||||
|
|
||||||
|
def system_blocks(self) -> list[str]:
|
||||||
|
"""Stable system-prompt blocks for this posture (brief + workspace).
|
||||||
|
|
||||||
|
The operating brief carries a model-family edit-format nudge appended
|
||||||
|
to it (one cached string, not a separate block) so the model is steered
|
||||||
|
toward the `patch` mode it handles best — see ``_edit_format_line``.
|
||||||
|
"""
|
||||||
|
if not self.is_coding:
|
||||||
|
return []
|
||||||
|
blocks: list[str] = []
|
||||||
|
if self.profile.guidance:
|
||||||
|
brief = self.profile.guidance
|
||||||
|
edit_line = _edit_format_line(self.model)
|
||||||
|
if edit_line:
|
||||||
|
brief = f"{brief}\n{edit_line}"
|
||||||
|
blocks.append(brief)
|
||||||
|
workspace = build_coding_workspace_block(self.cwd)
|
||||||
|
if workspace:
|
||||||
|
blocks.append(workspace)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def hidden_skill_categories(self) -> frozenset[str]:
|
||||||
|
"""Skill categories to prune from the prompt's skill index (may be empty)."""
|
||||||
|
return frozenset(self.profile.hidden_skill_categories)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_runtime_mode(
|
||||||
|
*,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
cwd: Optional[str | Path] = None,
|
||||||
|
config: Optional[dict[str, Any]] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
) -> RuntimeMode:
|
||||||
|
"""Resolve the operating posture once. Cheap — a handful of ``stat`` calls.
|
||||||
|
|
||||||
|
This is the single entry point every domain should call. The returned
|
||||||
|
object is immutable and safe to cache for the session. Detection itself is
|
||||||
|
intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived
|
||||||
|
process can't pin a stale posture; callers resolve once per session and
|
||||||
|
hold the result. ``model`` is recorded only to steer edit-format guidance;
|
||||||
|
it never affects detection.
|
||||||
|
"""
|
||||||
|
resolved_cwd = _resolve_cwd(cwd)
|
||||||
|
mode = _coding_mode(config)
|
||||||
|
name = _detect_profile_name(
|
||||||
|
mode, (platform or "").strip().lower(), str(resolved_cwd)
|
||||||
|
)
|
||||||
|
return RuntimeMode(
|
||||||
|
profile=get_profile(name),
|
||||||
|
surface=platform or "",
|
||||||
|
cwd=resolved_cwd,
|
||||||
|
config_mode=mode,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Back-compat surface (thin wrappers over RuntimeMode) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def is_coding_context(
|
||||||
|
*,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
cwd: Optional[str | Path] = None,
|
||||||
|
config: Optional[dict[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether Hermes should operate in its coding posture right now."""
|
||||||
|
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding
|
||||||
|
|
||||||
|
|
||||||
|
def coding_selection(
|
||||||
|
*,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
cwd: Optional[str | Path] = None,
|
||||||
|
config: Optional[dict[str, Any]] = None,
|
||||||
|
) -> Optional[list[str]]:
|
||||||
|
"""Toolset selection for the coding posture.
|
||||||
|
|
||||||
|
``None`` unless the user opted into ``focus`` mode AND the posture is
|
||||||
|
active — the default coding posture never overrides configured toolsets.
|
||||||
|
"""
|
||||||
|
return resolve_runtime_mode(
|
||||||
|
platform=platform, cwd=cwd, config=config
|
||||||
|
).toolset_selection(config)
|
||||||
|
|
||||||
|
|
||||||
|
def coding_system_blocks(
|
||||||
|
*,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
cwd: Optional[str | Path] = None,
|
||||||
|
config: Optional[dict[str, Any]] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Stable system-prompt blocks for the current posture (empty when general).
|
||||||
|
|
||||||
|
``model`` steers the brief's edit-format nudge toward the model's family.
|
||||||
|
"""
|
||||||
|
return resolve_runtime_mode(
|
||||||
|
platform=platform, cwd=cwd, config=config, model=model
|
||||||
|
).system_blocks()
|
||||||
|
|
||||||
|
|
||||||
|
def coding_hidden_skill_categories(
|
||||||
|
*,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
cwd: Optional[str | Path] = None,
|
||||||
|
config: Optional[dict[str, Any]] = None,
|
||||||
|
) -> frozenset[str]:
|
||||||
|
"""Skill categories the active posture prunes from the prompt's skill index.
|
||||||
|
|
||||||
|
Empty outside the coding posture. Discovery-only: hidden skills remain
|
||||||
|
loadable via ``skills_list`` / ``skill_view``.
|
||||||
|
"""
|
||||||
|
return resolve_runtime_mode(
|
||||||
|
platform=platform, cwd=cwd, config=config
|
||||||
|
).hidden_skill_categories()
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||||
|
"""Names of MCP servers the user has enabled — kept in the coding posture.
|
||||||
|
|
||||||
|
MCP servers (figma, browser, tophat, …) are explicitly configured and part
|
||||||
|
of the coding workflow, not noise to strip.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import read_raw_config
|
||||||
|
from hermes_cli.tools_config import _parse_enabled_flag
|
||||||
|
|
||||||
|
servers = read_raw_config().get("mcp_servers") or {}
|
||||||
|
return [
|
||||||
|
str(name)
|
||||||
|
for name, cfg in servers.items()
|
||||||
|
if isinstance(cfg, dict)
|
||||||
|
and _parse_enabled_flag(cfg.get("enabled", True), default=True)
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ── git/workspace probe ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: Path, *args: str) -> str:
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "-C", str(cwd), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=_GIT_TIMEOUT,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ""
|
||||||
|
return out.stdout.strip() if out.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
|
||||||
|
"""Parse ``git status --porcelain=2 --branch`` into branch + counts."""
|
||||||
|
branch: dict[str, str] = {}
|
||||||
|
counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
|
||||||
|
for line in porcelain.splitlines():
|
||||||
|
if line.startswith("# branch.head"):
|
||||||
|
branch["head"] = line.split(maxsplit=2)[-1]
|
||||||
|
elif line.startswith("# branch.upstream"):
|
||||||
|
branch["upstream"] = line.split(maxsplit=2)[-1]
|
||||||
|
elif line.startswith("# branch.ab"):
|
||||||
|
parts = line.split()
|
||||||
|
branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
|
||||||
|
elif line.startswith(("1 ", "2 ")):
|
||||||
|
xy = line.split(maxsplit=2)[1]
|
||||||
|
if xy[0] != ".":
|
||||||
|
counts["staged"] += 1
|
||||||
|
if xy[1] != ".":
|
||||||
|
counts["modified"] += 1
|
||||||
|
elif line.startswith("u "):
|
||||||
|
counts["conflicts"] += 1
|
||||||
|
elif line.startswith("? "):
|
||||||
|
counts["untracked"] += 1
|
||||||
|
return branch, counts
|
||||||
|
|
||||||
|
|
||||||
|
def _read_small(path: Path) -> str:
|
||||||
|
"""Read a small text file, or ``""`` — never raises, never reads huge files."""
|
||||||
|
try:
|
||||||
|
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
|
||||||
|
return ""
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _project_facts(root: Path) -> list[str]:
|
||||||
|
"""Detected project facts for the workspace snapshot.
|
||||||
|
|
||||||
|
The point is to hand the model its *verify loop* up front — which manifest,
|
||||||
|
which package manager, and the exact test/lint/build commands — instead of
|
||||||
|
making it rediscover them every session. Cheap: stat calls plus reads of a
|
||||||
|
couple of small files; built once at prompt-build time (cache-safe).
|
||||||
|
"""
|
||||||
|
facts: list[str] = []
|
||||||
|
|
||||||
|
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
|
||||||
|
package_managers = [
|
||||||
|
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
|
||||||
|
]
|
||||||
|
if manifests:
|
||||||
|
line = f"- Project: {', '.join(manifests[:6])}"
|
||||||
|
if package_managers:
|
||||||
|
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
|
||||||
|
facts.append(line)
|
||||||
|
|
||||||
|
verify: list[str] = []
|
||||||
|
if (root / "scripts" / "run_tests.sh").is_file():
|
||||||
|
verify.append("scripts/run_tests.sh")
|
||||||
|
if (root / "package.json").is_file():
|
||||||
|
try:
|
||||||
|
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
scripts = {}
|
||||||
|
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
|
||||||
|
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
|
||||||
|
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
|
||||||
|
verify.append("pytest")
|
||||||
|
makefile = _read_small(root / "Makefile")
|
||||||
|
if makefile:
|
||||||
|
verify.extend(
|
||||||
|
f"make {name}" for name in _VERIFY_TARGETS
|
||||||
|
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
|
||||||
|
)
|
||||||
|
if verify:
|
||||||
|
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
|
||||||
|
facts.append(f"- Verify: {'; '.join(deduped)}")
|
||||||
|
|
||||||
|
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
|
||||||
|
if context_files:
|
||||||
|
facts.append(f"- Context files: {', '.join(context_files)}")
|
||||||
|
|
||||||
|
return facts
|
||||||
|
|
||||||
|
|
||||||
|
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||||
|
"""Workspace snapshot for the system prompt (empty outside a workspace).
|
||||||
|
|
||||||
|
Git state (branch/status/commits) when the cwd is in a repo, plus detected
|
||||||
|
project facts (manifest, package manager, verify commands, context files)
|
||||||
|
— so marker-only (non-git) projects still get a snapshot.
|
||||||
|
"""
|
||||||
|
resolved = _resolve_cwd(cwd)
|
||||||
|
git_root = _git_root(resolved)
|
||||||
|
root = git_root or _marker_root(resolved)
|
||||||
|
if root is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
|
||||||
|
lines.append(f"- Root: {root}")
|
||||||
|
|
||||||
|
if git_root is not None:
|
||||||
|
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
|
||||||
|
head = branch.get("head", "")
|
||||||
|
if head and head != "(detached)":
|
||||||
|
line = f"- Branch: {head}"
|
||||||
|
if branch.get("upstream"):
|
||||||
|
line += f" \u2192 {branch['upstream']}"
|
||||||
|
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
|
||||||
|
if ahead != "0" or behind != "0":
|
||||||
|
line += f" (ahead {ahead}, behind {behind})"
|
||||||
|
lines.append(line)
|
||||||
|
elif head == "(detached)":
|
||||||
|
lines.append("- Branch: (detached HEAD)")
|
||||||
|
|
||||||
|
# Linked worktree: the per-worktree git dir differs from the shared common dir.
|
||||||
|
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
|
||||||
|
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
|
||||||
|
main_tree = Path(common_dir).resolve().parent
|
||||||
|
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
|
||||||
|
|
||||||
|
dirty = [f"{n} {label}" for label, n in (
|
||||||
|
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||||
|
("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]),
|
||||||
|
) if n]
|
||||||
|
lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
|
||||||
|
|
||||||
|
recent = _git(root, "log", "-3", "--pretty=%h %s")
|
||||||
|
if recent:
|
||||||
|
lines.append("- Recent commits:")
|
||||||
|
lines.extend(f" {c}" for c in recent.splitlines())
|
||||||
|
|
||||||
|
lines.extend(_project_facts(root))
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -246,7 +246,14 @@ def _expand_file_reference(
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return f"{ref.raw}: path is not a file", None
|
return f"{ref.raw}: path is not a file", None
|
||||||
if _is_binary_file(path):
|
if _is_binary_file(path):
|
||||||
return f"{ref.raw}: binary files are not supported", None
|
# A binary file can't be inlined as text, but it IS on disk (the agent's
|
||||||
|
# tools run where this resolves — the local cwd, or the staged copy in a
|
||||||
|
# remote session workspace). Returning a bare "not supported" warning
|
||||||
|
# with no content was a dead end: the model saw a failure and gave up
|
||||||
|
# (told the user the file type wasn't supported). Instead, hand it an
|
||||||
|
# actionable block — the path, type, size, and a nudge to use its tools —
|
||||||
|
# so it can read/convert/view the file itself.
|
||||||
|
return None, _binary_reference_block(ref, path)
|
||||||
|
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
if ref.line_start is not None:
|
if ref.line_start is not None:
|
||||||
@@ -290,6 +297,7 @@ def _expand_git_reference(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"{ref.raw}: git command timed out (30s)", None
|
return f"{ref.raw}: git command timed out (30s)", None
|
||||||
@@ -482,6 +490,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
@@ -491,6 +500,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
|||||||
return files[:limit]
|
return files[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _human_bytes(n: int) -> str:
|
||||||
|
size = float(n)
|
||||||
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if size < 1024 or unit == "GB":
|
||||||
|
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{size:.1f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
|
||||||
|
mime, _ = mimetypes.guess_type(path.name)
|
||||||
|
mime = mime or "application/octet-stream"
|
||||||
|
try:
|
||||||
|
size = _human_bytes(path.stat().st_size)
|
||||||
|
except OSError:
|
||||||
|
size = "unknown size"
|
||||||
|
return (
|
||||||
|
f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
|
||||||
|
f"It is available on disk at `{path}`. Use your tools to work with it "
|
||||||
|
f"(read or convert it, extract its text, or view/render it as needed); "
|
||||||
|
f"do not tell the user the file type is unsupported."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _file_metadata(path: Path) -> str:
|
def _file_metadata(path: Path) -> str:
|
||||||
if _is_binary_file(path):
|
if _is_binary_file(path):
|
||||||
return f"{path.stat().st_size} bytes"
|
return f"{path.stat().st_size} bytes"
|
||||||
|
|||||||
@@ -2221,30 +2221,54 @@ def run_conversation(
|
|||||||
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
|
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
|
||||||
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
|
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
|
||||||
|
|
||||||
# ── Thinking block signature recovery ─────────────────
|
# Thinking block signature recovery.
|
||||||
|
#
|
||||||
# Anthropic signs thinking blocks against the full turn
|
# Anthropic signs thinking blocks against the full turn
|
||||||
# content. Any upstream mutation (context compression,
|
# content. Any upstream mutation (context compression,
|
||||||
# session truncation, message merging) invalidates the
|
# session truncation, message merging) invalidates the
|
||||||
# signature → HTTP 400. Recovery: strip reasoning_details
|
# signature and the API replies HTTP 400 ("invalid
|
||||||
# from all messages so the next retry sends no thinking
|
# signature" or "cannot be modified"). Recovery strips
|
||||||
# blocks at all. One-shot — don't retry infinitely.
|
# ``reasoning_details`` so the retry sends no thinking
|
||||||
|
# blocks at all. One-shot per outer loop.
|
||||||
|
#
|
||||||
|
# The strip targets ``api_messages``, which is the
|
||||||
|
# API-call-time list that ``_build_api_kwargs`` consumes
|
||||||
|
# on every retry. ``api_messages`` was populated once at
|
||||||
|
# the start of the turn from shallow copies of
|
||||||
|
# ``messages``, so mutating it does not touch the
|
||||||
|
# canonical store. The previous implementation popped
|
||||||
|
# ``reasoning_details`` from ``messages`` instead, which
|
||||||
|
# had two problems: ``api_messages`` carried its own
|
||||||
|
# reference to the field through the shallow copy, so the
|
||||||
|
# retry's wire payload still included thinking blocks and
|
||||||
|
# the recovery never reached the API; and the mutation
|
||||||
|
# persisted into ``state.db`` through any subsequent
|
||||||
|
# ``_persist_session`` call, permanently corrupting the
|
||||||
|
# conversation. Future turns would replay the stripped
|
||||||
|
# state, hit the same 400, and the agent would terminate
|
||||||
|
# with ``max_retries_exhausted``, often spawning
|
||||||
|
# cascading compaction-ended sessions chained off the
|
||||||
|
# corrupted parent.
|
||||||
if (
|
if (
|
||||||
classified.reason == FailoverReason.thinking_signature
|
classified.reason == FailoverReason.thinking_signature
|
||||||
and not _retry.thinking_sig_retry_attempted
|
and not _retry.thinking_sig_retry_attempted
|
||||||
):
|
):
|
||||||
_retry.thinking_sig_retry_attempted = True
|
_retry.thinking_sig_retry_attempted = True
|
||||||
for _m in messages:
|
_api_stripped = 0
|
||||||
if isinstance(_m, dict):
|
for _m in api_messages:
|
||||||
|
if isinstance(_m, dict) and "reasoning_details" in _m:
|
||||||
_m.pop("reasoning_details", None)
|
_m.pop("reasoning_details", None)
|
||||||
|
_api_stripped += 1
|
||||||
agent._vprint(
|
agent._vprint(
|
||||||
f"{agent.log_prefix}⚠️ Thinking block signature invalid — "
|
f"{agent.log_prefix}⚠️ Thinking block signature invalid, "
|
||||||
f"stripped all thinking blocks, retrying...",
|
f"stripped reasoning_details from api_messages for retry...",
|
||||||
force=True,
|
force=True,
|
||||||
)
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%sThinking block signature recovery: stripped "
|
"%sThinking block signature recovery: stripped "
|
||||||
"reasoning_details from %d messages",
|
"reasoning_details from %d api_messages "
|
||||||
agent.log_prefix, len(messages),
|
"(canonical messages unchanged)",
|
||||||
|
agent.log_prefix, _api_stripped,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -194,17 +194,71 @@ class AgentNotice:
|
|||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── is_free_tier_model (local-data-only free-model check) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def is_free_tier_model(model: str, base_url: str = "") -> bool:
|
||||||
|
"""Return True when *model* is a Nous free-tier model, using ONLY local data.
|
||||||
|
|
||||||
|
Two signals, both zero-network:
|
||||||
|
|
||||||
|
1. The ``:free`` suffix — the canonical Nous free SKU marker (e.g.
|
||||||
|
``nvidia/nemotron-3-ultra:free``). Free by construction on the API side
|
||||||
|
(spend is forced to 0 for ``:free`` ids).
|
||||||
|
2. A peek into the in-process pricing cache in ``hermes_cli.models``
|
||||||
|
(populated when the model picker fetched ``/v1/models`` pricing for
|
||||||
|
*base_url*). PEEK ONLY — a cache miss never triggers a fetch. This is
|
||||||
|
CLI/TUI-session best-effort: gateway sessions never run the picker's
|
||||||
|
pricing fetch, so suppression there rests entirely on the ``:free``
|
||||||
|
suffix (which all Nous free SKUs carry).
|
||||||
|
|
||||||
|
Fail-open to False (the depleted notice still shows) on any error: wrongly
|
||||||
|
showing the warning is recoverable noise; wrongly hiding it on a paid model
|
||||||
|
would mask a real billing block.
|
||||||
|
"""
|
||||||
|
if not model:
|
||||||
|
return False
|
||||||
|
if model.endswith(":free"):
|
||||||
|
return True
|
||||||
|
if not base_url:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from hermes_cli.models import _is_model_free, _pricing_cache
|
||||||
|
|
||||||
|
# Mirror get_pricing_for_provider's key normalization: the agent's
|
||||||
|
# Nous base_url is /v1-suffixed (https://inference-api.nousresearch.com/v1)
|
||||||
|
# but the picker keys _pricing_cache on the pre-/v1 root.
|
||||||
|
key = base_url.rstrip("/")
|
||||||
|
if key.endswith("/v1"):
|
||||||
|
key = key[:-3].rstrip("/")
|
||||||
|
pricing = _pricing_cache.get(key)
|
||||||
|
if not pricing:
|
||||||
|
return False
|
||||||
|
return _is_model_free(model, pricing)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── evaluate_credits_notices (pure reconciliation function) ──────────────────
|
# ── evaluate_credits_notices (pure reconciliation function) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
def evaluate_credits_notices(
|
def evaluate_credits_notices(
|
||||||
state: CreditsState,
|
state: CreditsState,
|
||||||
latch: dict,
|
latch: dict,
|
||||||
|
*,
|
||||||
|
model_is_free: bool = False,
|
||||||
) -> tuple[list[AgentNotice], list[str]]:
|
) -> tuple[list[AgentNotice], list[str]]:
|
||||||
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
|
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
|
||||||
|
|
||||||
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
|
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
|
||||||
|
|
||||||
|
``model_is_free``: True when the session's active model is a Nous free-tier
|
||||||
|
model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
|
||||||
|
notice — a depleted account on a free model can keep inferencing, so the
|
||||||
|
error banner is noise (and confuses free-tier users who never had credits).
|
||||||
|
Suppression does NOT emit the "restored" success notice; that fires only on
|
||||||
|
a genuine ``paid_access`` flip back to True.
|
||||||
|
|
||||||
Returns ``(to_show: list[AgentNotice], to_clear: list[str])``.
|
Returns ``(to_show: list[AgentNotice], to_clear: list[str])``.
|
||||||
Caller emits to_clear FIRST, then to_show.
|
Caller emits to_clear FIRST, then to_show.
|
||||||
|
|
||||||
@@ -284,7 +338,11 @@ def evaluate_credits_notices(
|
|||||||
active.discard("credits.grant_spent")
|
active.discard("credits.grant_spent")
|
||||||
|
|
||||||
# ── depleted ─────────────────────────────────────────────────────────────
|
# ── depleted ─────────────────────────────────────────────────────────────
|
||||||
if depleted_cond and "credits.depleted" not in active:
|
# Suppressed while the active model is free: inference still works there,
|
||||||
|
# so the error banner would just alarm users (free-tier users especially,
|
||||||
|
# who never had paid credits to "lose").
|
||||||
|
show_depleted = depleted_cond and not model_is_free
|
||||||
|
if show_depleted and "credits.depleted" not in active:
|
||||||
to_show.append(
|
to_show.append(
|
||||||
AgentNotice(
|
AgentNotice(
|
||||||
text="✕ Credit access paused · run /usage for balance",
|
text="✕ Credit access paused · run /usage for balance",
|
||||||
@@ -295,20 +353,23 @@ def evaluate_credits_notices(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
active.add("credits.depleted")
|
active.add("credits.depleted")
|
||||||
elif "credits.depleted" in active and not depleted_cond:
|
elif "credits.depleted" in active and not show_depleted:
|
||||||
to_clear.append("credits.depleted")
|
to_clear.append("credits.depleted")
|
||||||
active.discard("credits.depleted")
|
active.discard("credits.depleted")
|
||||||
# Recovery: also emit the success notice
|
if not depleted_cond:
|
||||||
to_show.append(
|
# Genuine recovery (paid_access flipped back True): also emit the
|
||||||
AgentNotice(
|
# success notice. A clear caused by switching to a free model while
|
||||||
text="✓ Credit access restored",
|
# still depleted must NOT claim access was restored.
|
||||||
level="success",
|
to_show.append(
|
||||||
kind="ttl",
|
AgentNotice(
|
||||||
ttl_ms=CREDITS_RESTORED_TTL_MS,
|
text="✓ Credit access restored",
|
||||||
key="credits.restored",
|
level="success",
|
||||||
id="credits.restored",
|
kind="ttl",
|
||||||
|
ttl_ms=CREDITS_RESTORED_TTL_MS,
|
||||||
|
key="credits.restored",
|
||||||
|
id="credits.restored",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return (to_show, to_clear)
|
return (to_show, to_clear)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -33,6 +32,7 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
|
|||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from tools import skill_usage
|
from tools import skill_usage
|
||||||
|
from utils import atomic_json_write
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]:
|
|||||||
def save_state(data: Dict[str, Any]) -> None:
|
def save_state(data: Dict[str, Any]) -> None:
|
||||||
path = _state_file()
|
path = _state_file()
|
||||||
try:
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
atomic_json_write(path, data, indent=2, sort_keys=True)
|
||||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp, path)
|
|
||||||
except BaseException:
|
|
||||||
try:
|
|
||||||
os.unlink(tmp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -858,6 +858,20 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
|
|||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _used_free_parallel(result: str | None) -> bool:
|
||||||
|
"""True when a web result came from Parallel's free Search MCP.
|
||||||
|
|
||||||
|
Only the keyless Parallel path tags its result with ``provider="parallel"``;
|
||||||
|
the paid REST path and every other provider omit it. Used to label the tool
|
||||||
|
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
|
||||||
|
the call.
|
||||||
|
"""
|
||||||
|
if not isinstance(result, str) or '"provider"' not in result:
|
||||||
|
return False
|
||||||
|
data = safe_json_loads(result)
|
||||||
|
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
|
||||||
|
|
||||||
|
|
||||||
def get_cute_tool_message(
|
def get_cute_tool_message(
|
||||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -895,15 +909,17 @@ def get_cute_tool_message(
|
|||||||
return f"{line}{failure_suffix}"
|
return f"{line}{failure_suffix}"
|
||||||
|
|
||||||
if tool_name == "web_search":
|
if tool_name == "web_search":
|
||||||
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
|
verb = "Parallel search" if _used_free_parallel(result) else "search"
|
||||||
|
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
|
||||||
if tool_name == "web_extract":
|
if tool_name == "web_extract":
|
||||||
|
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
|
||||||
urls = args.get("urls", [])
|
urls = args.get("urls", [])
|
||||||
if urls:
|
if urls:
|
||||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
|
||||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
|
||||||
if tool_name == "terminal":
|
if tool_name == "terminal":
|
||||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||||
if tool_name == "process":
|
if tool_name == "process":
|
||||||
|
|||||||
@@ -549,14 +549,32 @@ def classify_api_error(
|
|||||||
should_fallback=True,
|
should_fallback=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Anthropic thinking block signature invalid (400).
|
# Anthropic thinking block recovery (400). Two distinct failure modes,
|
||||||
|
# same recovery (strip all reasoning_details and retry without thinking
|
||||||
|
# blocks — see the thinking_signature handler in conversation_loop.py):
|
||||||
|
# 1. Signature mismatch: a thinking block is signed against the full
|
||||||
|
# turn content; any upstream mutation (context compression, session
|
||||||
|
# truncation, message merging) invalidates the signature.
|
||||||
|
# Pattern: "signature" + "thinking".
|
||||||
|
# 2. Frozen-block mutation: Anthropic rejects any change to the
|
||||||
|
# thinking/redacted_thinking blocks in the *latest* assistant
|
||||||
|
# message — "`thinking` or `redacted_thinking` blocks in the latest
|
||||||
|
# assistant message cannot be modified. These blocks must remain as
|
||||||
|
# they were in the original response." This carries no "signature"
|
||||||
|
# token, so the original pattern missed it and the turn hard-aborted
|
||||||
|
# as a non-retryable client error instead of self-healing.
|
||||||
|
# Pattern: "thinking" + ("cannot be modified" | "must remain as they were").
|
||||||
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
|
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
|
||||||
# provider may be "openrouter" even though the error is Anthropic-specific.
|
# provider may be "openrouter" even though the error is Anthropic-specific.
|
||||||
# The message pattern ("signature" + "thinking") is unique enough.
|
# The combined patterns are unique enough.
|
||||||
if (
|
if (
|
||||||
status_code == 400
|
status_code == 400
|
||||||
and "signature" in error_msg
|
|
||||||
and "thinking" in error_msg
|
and "thinking" in error_msg
|
||||||
|
and (
|
||||||
|
"signature" in error_msg
|
||||||
|
or "cannot be modified" in error_msg
|
||||||
|
or "must remain as they were" in error_msg
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return _result(
|
return _result(
|
||||||
FailoverReason.thinking_signature,
|
FailoverReason.thinking_signature,
|
||||||
@@ -966,6 +984,34 @@ def _classify_400(
|
|||||||
should_fallback=False,
|
should_fallback=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Request-validation errors (unsupported / unknown parameter) MUST be
|
||||||
|
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
|
||||||
|
# returns:
|
||||||
|
# "Unsupported parameter: 'max_tokens' is not supported with this model.
|
||||||
|
# Use 'max_completion_tokens' instead."
|
||||||
|
# That string contains the literal substring "max_tokens", which is one of
|
||||||
|
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
|
||||||
|
# misclassified as context_overflow, routed into the compression loop,
|
||||||
|
# re-sent with the same bad parameter, and ends in "Cannot compress
|
||||||
|
# further". These errors are deterministic (every retry gets the identical
|
||||||
|
# rejection), so classify as a non-retryable format_error and fall back.
|
||||||
|
#
|
||||||
|
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
|
||||||
|
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
|
||||||
|
# so matching it would mis-route real overflows away from compression. The
|
||||||
|
# unambiguous signals are the explicit "unsupported/unknown parameter"
|
||||||
|
# message text and the specific parameter-level error codes.
|
||||||
|
if (
|
||||||
|
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
|
||||||
|
if p != "invalid_request_error")
|
||||||
|
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
|
||||||
|
):
|
||||||
|
return result_fn(
|
||||||
|
FailoverReason.format_error,
|
||||||
|
retryable=False,
|
||||||
|
should_fallback=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Context overflow from 400
|
# Context overflow from 400
|
||||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||||
return result_fn(
|
return result_fn(
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ def _install_npm(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -310,6 +311,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=600,
|
timeout=600,
|
||||||
env=env,
|
env=env,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -347,6 +349,7 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||||||
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
|
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
|
||||||
# substring of "anthropic/claude-sonnet-4.6").
|
# substring of "anthropic/claude-sonnet-4.6").
|
||||||
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
|
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
|
||||||
|
"claude-fable-5": 1000000,
|
||||||
|
"claude-fable": 1000000,
|
||||||
"claude-opus-4-8": 1000000,
|
"claude-opus-4-8": 1000000,
|
||||||
"claude-opus-4.8": 1000000,
|
"claude-opus-4.8": 1000000,
|
||||||
"claude-opus-4-7": 1000000,
|
"claude-opus-4-7": 1000000,
|
||||||
@@ -968,6 +970,16 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
|||||||
# OpenRouter/Nous phrasing of the same condition.
|
# OpenRouter/Nous phrasing of the same condition.
|
||||||
"in the output" in error_lower
|
"in the output" in error_lower
|
||||||
and "maximum context length" in error_lower
|
and "maximum context length" in error_lower
|
||||||
|
) or (
|
||||||
|
# LM Studio / llama.cpp / some OpenAI-compatible servers:
|
||||||
|
# "This model's maximum context length is 65536 tokens. However, you
|
||||||
|
# requested 65536 output tokens and your prompt contains 77409
|
||||||
|
# characters ..."
|
||||||
|
# The "requested N output tokens" phrasing means the OUTPUT cap is the
|
||||||
|
# problem (the input itself fits) — reduce max_tokens, don't compress.
|
||||||
|
"maximum context length" in error_lower
|
||||||
|
and "requested" in error_lower
|
||||||
|
and "output tokens" in error_lower
|
||||||
)
|
)
|
||||||
if not is_output_cap_error:
|
if not is_output_cap_error:
|
||||||
return None
|
return None
|
||||||
@@ -999,6 +1011,22 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
|||||||
if _available >= 1:
|
if _available >= 1:
|
||||||
return _available
|
return _available
|
||||||
|
|
||||||
|
# LM Studio / llama.cpp style: context window is reported in tokens but the
|
||||||
|
# prompt size is reported in CHARACTERS, e.g.
|
||||||
|
# "maximum context length is 65536 tokens ... your prompt contains 77409
|
||||||
|
# characters ...".
|
||||||
|
# Estimate the input tokens conservatively (~3 chars/token, which
|
||||||
|
# over-reserves the input so the retried output cap stays safely inside the
|
||||||
|
# window) and leave the remainder of the window for output.
|
||||||
|
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
|
||||||
|
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
|
||||||
|
if _m_ctx_tok and _m_chars:
|
||||||
|
_ctx = int(_m_ctx_tok.group(1))
|
||||||
|
_est_input = (int(_m_chars.group(1)) + 2) // 3
|
||||||
|
_available = _ctx - _est_input
|
||||||
|
if _available >= 1:
|
||||||
|
return _available
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -1784,10 +1812,43 @@ def get_model_context_length(
|
|||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
save_context_length(model, base_url, ctx)
|
save_context_length(model, base_url, ctx)
|
||||||
return ctx
|
return ctx
|
||||||
|
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
|
||||||
|
# models. OpenRouter's catalog carries per-model context_length (e.g.
|
||||||
|
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
|
||||||
|
# must win over both models.dev (step 5g) and the hardcoded family catch-all
|
||||||
|
# (step 8). Before this branch, an OpenRouter selection set
|
||||||
|
# effective_provider="openrouter", which (a) made the models.dev lookup miss
|
||||||
|
# brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
|
||||||
|
# effective_provider`), so a fresh slug like claude-fable-5 fell through to
|
||||||
|
# the generic "claude": 200K entry and under-reported a 1M window. Mirrors
|
||||||
|
# the dedicated Nous/Copilot/GMI branches above.
|
||||||
|
if effective_provider == "openrouter":
|
||||||
|
metadata = fetch_model_metadata()
|
||||||
|
entry = metadata.get(model)
|
||||||
|
if entry:
|
||||||
|
or_ctx = entry.get("context_length")
|
||||||
|
# Guard against the known OpenRouter Kimi-family 32k underreport
|
||||||
|
# (same class the hardcoded overrides exist to mitigate).
|
||||||
|
if isinstance(or_ctx, int) and or_ctx > 0 and not (
|
||||||
|
or_ctx == 32768 and _model_name_suggests_kimi(model)
|
||||||
|
):
|
||||||
|
return or_ctx
|
||||||
|
|
||||||
if effective_provider:
|
if effective_provider:
|
||||||
from agent.models_dev import lookup_models_dev_context
|
from agent.models_dev import lookup_models_dev_context
|
||||||
ctx = lookup_models_dev_context(effective_provider, model)
|
ctx = lookup_models_dev_context(effective_provider, model)
|
||||||
if ctx:
|
if ctx:
|
||||||
|
# MiniMax M3: models.dev reports 512K but actual context is 1M.
|
||||||
|
# Prefer hardcoded catalog over stale probe value.
|
||||||
|
if _model_name_suggests_minimax_m3(model):
|
||||||
|
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
|
||||||
|
if catalog and ctx < catalog:
|
||||||
|
logger.info(
|
||||||
|
"Rejecting models.dev context=%s for %r "
|
||||||
|
"(MiniMax-M3 underreport); using hardcoded default %s",
|
||||||
|
ctx, model, f"{catalog:,}",
|
||||||
|
)
|
||||||
|
ctx = catalog
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
# 6. OpenRouter live API metadata — provider-unaware fallback.
|
# 6. OpenRouter live API metadata — provider-unaware fallback.
|
||||||
|
|||||||
@@ -885,6 +885,22 @@ def build_environment_hints() -> str:
|
|||||||
f"`uname -a && whoami && pwd`."
|
f"`uname -a && whoami && pwd`."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Hermes desktop GUI — any agent running under the desktop app should know
|
||||||
|
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
|
||||||
|
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
|
||||||
|
_truthy = ("1", "true", "yes")
|
||||||
|
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
|
||||||
|
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
|
||||||
|
if _in_desktop or _in_desktop_term:
|
||||||
|
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
|
||||||
|
if _in_desktop_term:
|
||||||
|
_desktop_hint += (
|
||||||
|
" You're in its embedded terminal pane, beside the GUI chat — the user can "
|
||||||
|
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
|
||||||
|
"⌘/Ctrl+L to send it to the chat composer."
|
||||||
|
)
|
||||||
|
hints.append(_desktop_hint)
|
||||||
|
|
||||||
if is_wsl():
|
if is_wsl():
|
||||||
hints.append(WSL_ENVIRONMENT_HINT)
|
hints.append(WSL_ENVIRONMENT_HINT)
|
||||||
|
|
||||||
@@ -1085,11 +1101,12 @@ def _skill_should_show(
|
|||||||
def build_skills_system_prompt(
|
def build_skills_system_prompt(
|
||||||
available_tools: "set[str] | None" = None,
|
available_tools: "set[str] | None" = None,
|
||||||
available_toolsets: "set[str] | None" = None,
|
available_toolsets: "set[str] | None" = None,
|
||||||
|
hidden_categories: "frozenset[str] | None" = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a compact skill index for the system prompt.
|
"""Build a compact skill index for the system prompt.
|
||||||
|
|
||||||
Two-layer cache:
|
Two-layer cache:
|
||||||
1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
|
1. In-process LRU dict keyed by (skills_dir, tools, toolsets, hidden)
|
||||||
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
|
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
|
||||||
mtime/size manifest — survives process restarts
|
mtime/size manifest — survives process restarts
|
||||||
|
|
||||||
@@ -1099,6 +1116,12 @@ def build_skills_system_prompt(
|
|||||||
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
||||||
are read-only — they appear in the index but new skills are always created
|
are read-only — they appear in the index but new skills are always created
|
||||||
in the local dir. Local skills take precedence when names collide.
|
in the local dir. Local skills take precedence when names collide.
|
||||||
|
|
||||||
|
``hidden_categories`` (e.g. from the coding posture — see
|
||||||
|
agent/coding_context.py) prunes whole categories from the rendered index.
|
||||||
|
Discovery-only: the snapshot stores everything, ``skills_list`` /
|
||||||
|
``skill_view`` still reach every skill, and a footer note tells the model
|
||||||
|
the full catalog exists.
|
||||||
"""
|
"""
|
||||||
skills_dir = get_skills_dir()
|
skills_dir = get_skills_dir()
|
||||||
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
||||||
@@ -1123,6 +1146,7 @@ def build_skills_system_prompt(
|
|||||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||||
_platform_hint,
|
_platform_hint,
|
||||||
tuple(sorted(disabled)),
|
tuple(sorted(disabled)),
|
||||||
|
tuple(sorted(hidden_categories or ())),
|
||||||
)
|
)
|
||||||
with _SKILLS_PROMPT_CACHE_LOCK:
|
with _SKILLS_PROMPT_CACHE_LOCK:
|
||||||
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
|
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
|
||||||
@@ -1256,6 +1280,26 @@ def build_skills_system_prompt(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
||||||
|
|
||||||
|
# Posture-driven category pruning (e.g. non-coding skills while pairing on
|
||||||
|
# code). Match on the top-level category segment so nested categories
|
||||||
|
# ("social-media/twitter") are pruned with their parent.
|
||||||
|
hidden_note = ""
|
||||||
|
if hidden_categories:
|
||||||
|
before = sum(len(v) for v in skills_by_category.values())
|
||||||
|
skills_by_category = {
|
||||||
|
cat: entries
|
||||||
|
for cat, entries in skills_by_category.items()
|
||||||
|
if cat.split("/", 1)[0] not in hidden_categories
|
||||||
|
}
|
||||||
|
pruned = before - sum(len(v) for v in skills_by_category.values())
|
||||||
|
if pruned:
|
||||||
|
hidden_note = (
|
||||||
|
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
|
||||||
|
"current coding context are not listed here. The full catalog "
|
||||||
|
"is available via skills_list if the user asks for something "
|
||||||
|
"outside this list.)"
|
||||||
|
)
|
||||||
|
|
||||||
if not skills_by_category:
|
if not skills_by_category:
|
||||||
result = ""
|
result = ""
|
||||||
else:
|
else:
|
||||||
@@ -1304,6 +1348,7 @@ def build_skills_system_prompt(
|
|||||||
"</available_skills>\n"
|
"</available_skills>\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Only proceed without loading a skill if genuinely none are relevant to the task."
|
"Only proceed without loading a skill if genuinely none are relevant to the task."
|
||||||
|
+ hidden_note
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Store in LRU cache ────────────────────────────────────────────
|
# ── Store in LRU cache ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ def _platform_asset_name() -> str:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=2,
|
timeout=2,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if "musl" in (res.stdout + res.stderr).lower():
|
if "musl" in (res.stdout + res.stderr).lower():
|
||||||
libc = "musl"
|
libc = "musl"
|
||||||
@@ -525,6 +526,7 @@ def _run_bws_list(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=_BWS_RUN_TIMEOUT,
|
timeout=_BWS_RUN_TIMEOUT,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=max(1, int(timeout)),
|
timeout=max(1, int(timeout)),
|
||||||
check=False,
|
check=False,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||||
|
|||||||
@@ -191,9 +191,21 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
)
|
)
|
||||||
if toolset
|
if toolset
|
||||||
}
|
}
|
||||||
|
# Coding posture prunes non-coding skill categories from the index
|
||||||
|
# (discovery-only — skills_list/skill_view still reach everything).
|
||||||
|
_hidden_cats = frozenset()
|
||||||
|
try:
|
||||||
|
from agent.coding_context import coding_hidden_skill_categories
|
||||||
|
|
||||||
|
_hidden_cats = coding_hidden_skill_categories(
|
||||||
|
platform=agent.platform, cwd=resolve_context_cwd()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_hidden_cats = frozenset()
|
||||||
skills_prompt = _r.build_skills_system_prompt(
|
skills_prompt = _r.build_skills_system_prompt(
|
||||||
available_tools=agent.valid_tool_names,
|
available_tools=agent.valid_tool_names,
|
||||||
available_toolsets=avail_toolsets,
|
available_toolsets=avail_toolsets,
|
||||||
|
hidden_categories=_hidden_cats or None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
skills_prompt = ""
|
skills_prompt = ""
|
||||||
@@ -221,6 +233,26 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
|||||||
if _env_hints:
|
if _env_hints:
|
||||||
stable_parts.append(_env_hints)
|
stable_parts.append(_env_hints)
|
||||||
|
|
||||||
|
# Coding posture (base Hermes, any interactive coding surface in a code
|
||||||
|
# workspace — see agent/coding_context.py). The operating brief + the live
|
||||||
|
# git/workspace snapshot are built once here and cached for the session;
|
||||||
|
# the snapshot is never re-probed per turn (that would break the prompt
|
||||||
|
# cache), so the brief tells the model to re-check git before relying on it.
|
||||||
|
if agent.valid_tool_names:
|
||||||
|
try:
|
||||||
|
from agent.coding_context import coding_system_blocks
|
||||||
|
|
||||||
|
stable_parts.extend(
|
||||||
|
coding_system_blocks(
|
||||||
|
platform=agent.platform,
|
||||||
|
cwd=resolve_context_cwd(),
|
||||||
|
model=agent.model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Coding-context probing must never block prompt build.
|
||||||
|
pass
|
||||||
|
|
||||||
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
|
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
|
||||||
# something is non-default so the model can pick the right install
|
# something is non-default so the model can pick the right install
|
||||||
# strategy without discovering by failure. Emits a single line; emits
|
# strategy without discovering by failure. Emits a single line; emits
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||||||
|
|
||||||
# ── Logging / callbacks ──────────────────────────────────────────
|
# ── Logging / callbacks ──────────────────────────────────────────
|
||||||
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
|
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
|
||||||
if not agent.quiet_mode:
|
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||||
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
|
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
|
||||||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
|
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
|
||||||
args_str = json.dumps(args, ensure_ascii=False)
|
args_str = json.dumps(args, ensure_ascii=False)
|
||||||
@@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||||||
if agent._should_emit_quiet_tool_messages():
|
if agent._should_emit_quiet_tool_messages():
|
||||||
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
|
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
|
||||||
agent._safe_print(f" {cute_msg}")
|
agent._safe_print(f" {cute_msg}")
|
||||||
elif getattr(agent, "tool_progress_mode", "all") != "off":
|
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||||
_preview_str = _multimodal_text_summary(function_result)
|
_preview_str = _multimodal_text_summary(function_result)
|
||||||
if agent.verbose_logging:
|
if agent.verbose_logging:
|
||||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
|
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
|
||||||
@@ -866,7 +866,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
elif function_name == "skill_manage":
|
elif function_name == "skill_manage":
|
||||||
agent._iters_since_skill = 0
|
agent._iters_since_skill = 0
|
||||||
|
|
||||||
if not agent.quiet_mode:
|
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||||
args_str = json.dumps(function_args, ensure_ascii=False)
|
args_str = json.dumps(function_args, ensure_ascii=False)
|
||||||
if agent.verbose_logging:
|
if agent.verbose_logging:
|
||||||
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
|
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
|
||||||
@@ -1065,6 +1065,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
tool_duration = time.time() - tool_start_time
|
tool_duration = time.time() - tool_start_time
|
||||||
if agent._should_emit_quiet_tool_messages():
|
if agent._should_emit_quiet_tool_messages():
|
||||||
agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
|
agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
|
||||||
|
elif function_name == "read_terminal":
|
||||||
|
def _execute(next_args: dict) -> Any:
|
||||||
|
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
|
||||||
|
return _read_terminal_tool(
|
||||||
|
start_line=next_args.get("start_line"),
|
||||||
|
count=next_args.get("count"),
|
||||||
|
callback=getattr(agent, "read_terminal_callback", None),
|
||||||
|
)
|
||||||
|
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||||
|
agent,
|
||||||
|
function_name=function_name,
|
||||||
|
function_args=function_args,
|
||||||
|
effective_task_id=effective_task_id,
|
||||||
|
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||||
|
execute=_execute,
|
||||||
|
)
|
||||||
|
tool_duration = time.time() - tool_start_time
|
||||||
|
if agent._should_emit_quiet_tool_messages():
|
||||||
|
agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}")
|
||||||
elif function_name == "delegate_task":
|
elif function_name == "delegate_task":
|
||||||
tasks_arg = function_args.get("tasks")
|
tasks_arg = function_args.get("tasks")
|
||||||
if tasks_arg and isinstance(tasks_arg, list):
|
if tasks_arg and isinstance(tasks_arg, list):
|
||||||
@@ -1365,7 +1384,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
# entire batch. The model sees it on the next API iteration.
|
# entire batch. The model sees it on the next API iteration.
|
||||||
agent._apply_pending_steer_to_tool_results(messages, 1)
|
agent._apply_pending_steer_to_tool_results(messages, 1)
|
||||||
|
|
||||||
if not agent.quiet_mode:
|
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||||
if agent.verbose_logging:
|
if agent.verbose_logging:
|
||||||
print(f" ✅ Tool {i} completed in {tool_duration:.2f}s")
|
print(f" ✅ Tool {i} completed in {tool_duration:.2f}s")
|
||||||
print(agent._wrap_verbose("Result: ", function_result))
|
print(agent._wrap_verbose("Result: ", function_result))
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class AnthropicTransport(ProviderTransport):
|
|||||||
to OpenAI finish_reason, and collects reasoning_details in provider_data.
|
to OpenAI finish_reason, and collects reasoning_details in provider_data.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from agent.anthropic_adapter import _to_plain_data
|
from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
|
||||||
from agent.transports.types import ToolCall
|
from agent.transports.types import ToolCall
|
||||||
|
|
||||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||||
@@ -94,14 +94,40 @@ class AnthropicTransport(ProviderTransport):
|
|||||||
reasoning_parts = []
|
reasoning_parts = []
|
||||||
reasoning_details = []
|
reasoning_details = []
|
||||||
tool_calls = []
|
tool_calls = []
|
||||||
|
# Verbatim, order-preserving copy of every content block in the turn.
|
||||||
|
# Anthropic signs each thinking block against the turn content that
|
||||||
|
# PRECEDES it at its position; when a turn interleaves thinking and
|
||||||
|
# tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel
|
||||||
|
# reasoning_details + tool_calls lists below lose that cross-type
|
||||||
|
# ordering. Replaying the latest assistant message in the wrong order
|
||||||
|
# invalidates the signatures -> HTTP 400 "thinking ... blocks in the
|
||||||
|
# latest assistant message cannot be modified". Preserve the exact
|
||||||
|
# block sequence here so the adapter can replay it unchanged. See
|
||||||
|
# tests/agent/test_anthropic_thinking_block_order.py.
|
||||||
|
ordered_blocks = []
|
||||||
|
|
||||||
for block in response.content:
|
for block in response.content:
|
||||||
|
block_dict = _to_plain_data(block)
|
||||||
|
clean_block = None
|
||||||
|
if isinstance(block_dict, dict):
|
||||||
|
# Sanitize at capture so output-only SDK fields (parsed_output,
|
||||||
|
# caller, citations=None, …) never persist to state.db and leak
|
||||||
|
# back as request input on replay → HTTP 400 "Extra inputs are
|
||||||
|
# not permitted". Defence-in-depth with the replay-side sanitize.
|
||||||
|
clean_block = _sanitize_replay_block(block_dict)
|
||||||
|
if clean_block is not None:
|
||||||
|
ordered_blocks.append(clean_block)
|
||||||
if block.type == "text":
|
if block.type == "text":
|
||||||
text_parts.append(block.text)
|
text_parts.append(block.text)
|
||||||
elif block.type == "thinking":
|
elif block.type in ("thinking", "redacted_thinking"):
|
||||||
reasoning_parts.append(block.thinking)
|
if block.type == "thinking":
|
||||||
block_dict = _to_plain_data(block)
|
reasoning_parts.append(block.thinking)
|
||||||
if isinstance(block_dict, dict):
|
# Use the sanitized block (clean_block) for reasoning_details too,
|
||||||
|
# since _extract_preserved_thinking_blocks replays these on the
|
||||||
|
# non-ordered path. Falls back to raw only if sanitize dropped it.
|
||||||
|
if isinstance(clean_block, dict):
|
||||||
|
reasoning_details.append(clean_block)
|
||||||
|
elif isinstance(block_dict, dict):
|
||||||
reasoning_details.append(block_dict)
|
reasoning_details.append(block_dict)
|
||||||
elif block.type == "tool_use":
|
elif block.type == "tool_use":
|
||||||
name = block.name
|
name = block.name
|
||||||
@@ -130,6 +156,23 @@ class AnthropicTransport(ProviderTransport):
|
|||||||
provider_data = {}
|
provider_data = {}
|
||||||
if reasoning_details:
|
if reasoning_details:
|
||||||
provider_data["reasoning_details"] = reasoning_details
|
provider_data["reasoning_details"] = reasoning_details
|
||||||
|
# Only worth carrying the ordered-blocks channel when the turn
|
||||||
|
# actually interleaves signed thinking with tool_use — that's the
|
||||||
|
# only shape the parallel lists reconstruct incorrectly. A turn that
|
||||||
|
# is purely text, or thinking-then-tools with a single leading
|
||||||
|
# thinking block, replays correctly without it.
|
||||||
|
_has_signed_thinking = any(
|
||||||
|
isinstance(b, dict)
|
||||||
|
and b.get("type") in ("thinking", "redacted_thinking")
|
||||||
|
and (b.get("signature") or b.get("data"))
|
||||||
|
for b in ordered_blocks
|
||||||
|
)
|
||||||
|
_has_tool_use = any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_use"
|
||||||
|
for b in ordered_blocks
|
||||||
|
)
|
||||||
|
if _has_signed_thinking and _has_tool_use:
|
||||||
|
provider_data["anthropic_content_blocks"] = ordered_blocks
|
||||||
|
|
||||||
return NormalizedResponse(
|
return NormalizedResponse(
|
||||||
content="\n".join(text_parts) if text_parts else None,
|
content="\n".join(text_parts) if text_parts else None,
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ def check_codex_binary(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False, (
|
return False, (
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class TurnResult:
|
|||||||
error: Optional[str] = None # Set if turn ended in a non-recoverable error
|
error: Optional[str] = None # Set if turn ended in a non-recoverable error
|
||||||
turn_id: Optional[str] = None
|
turn_id: Optional[str] = None
|
||||||
thread_id: Optional[str] = None
|
thread_id: Optional[str] = None
|
||||||
|
token_usage_last: Optional[dict[str, Any]] = None
|
||||||
|
token_usage_total: Optional[dict[str, Any]] = None
|
||||||
|
model_context_window: Optional[int] = None
|
||||||
# Hint to the caller that the underlying codex subprocess is likely
|
# Hint to the caller that the underlying codex subprocess is likely
|
||||||
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
|
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
|
||||||
# token-refresh failure killed the child). The caller should retire
|
# token-refresh failure killed the child). The caller should retire
|
||||||
@@ -501,6 +504,7 @@ class CodexAppServerSession:
|
|||||||
pending = self._client.take_notification(timeout=0)
|
pending = self._client.take_notification(timeout=0)
|
||||||
if pending is None:
|
if pending is None:
|
||||||
break
|
break
|
||||||
|
_apply_token_usage_notification(result, pending)
|
||||||
self._track_pending_file_change(pending)
|
self._track_pending_file_change(pending)
|
||||||
proj = projector.project(pending)
|
proj = projector.project(pending)
|
||||||
if proj.messages:
|
if proj.messages:
|
||||||
@@ -536,6 +540,8 @@ class CodexAppServerSession:
|
|||||||
except Exception: # pragma: no cover - display callback
|
except Exception: # pragma: no cover - display callback
|
||||||
logger.debug("on_event callback raised", exc_info=True)
|
logger.debug("on_event callback raised", exc_info=True)
|
||||||
|
|
||||||
|
_apply_token_usage_notification(result, note)
|
||||||
|
|
||||||
# Track in-progress fileChange items so the approval bridge
|
# Track in-progress fileChange items so the approval bridge
|
||||||
# can surface a real change summary when codex requests
|
# can surface a real change summary when codex requests
|
||||||
# approval (the approval params themselves don't carry the
|
# approval (the approval params themselves don't carry the
|
||||||
@@ -802,6 +808,30 @@ class CodexAppServerSession:
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
|
||||||
|
"""Capture Codex app-server token usage updates for caller accounting.
|
||||||
|
|
||||||
|
Codex does not put token usage on turn/completed. It emits a separate
|
||||||
|
thread/tokenUsage/updated notification containing cumulative totals and
|
||||||
|
the latest turn breakdown.
|
||||||
|
"""
|
||||||
|
if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated":
|
||||||
|
return
|
||||||
|
params = note.get("params") or {}
|
||||||
|
token_usage = params.get("tokenUsage") or {}
|
||||||
|
if not isinstance(token_usage, dict):
|
||||||
|
return
|
||||||
|
last = token_usage.get("last")
|
||||||
|
total = token_usage.get("total")
|
||||||
|
if isinstance(last, dict):
|
||||||
|
result.token_usage_last = dict(last)
|
||||||
|
if isinstance(total, dict):
|
||||||
|
result.token_usage_total = dict(total)
|
||||||
|
window = token_usage.get("modelContextWindow")
|
||||||
|
if isinstance(window, int) and window > 0:
|
||||||
|
result.model_context_window = window
|
||||||
|
|
||||||
|
|
||||||
def _approval_choice_to_codex_decision(choice: str) -> str:
|
def _approval_choice_to_codex_decision(choice: str) -> str:
|
||||||
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
|
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
|
||||||
/ FileChangeApprovalDecision wire values.
|
/ FileChangeApprovalDecision wire values.
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ class NormalizedResponse:
|
|||||||
pd = self.provider_data or {}
|
pd = self.provider_data or {}
|
||||||
return pd.get("reasoning_details")
|
return pd.get("reasoning_details")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anthropic_content_blocks(self):
|
||||||
|
"""Verbatim, order-preserving Anthropic content blocks for a turn.
|
||||||
|
|
||||||
|
Present only when an Anthropic turn interleaves signed thinking with
|
||||||
|
tool_use — the one shape the parallel reasoning_details + tool_calls
|
||||||
|
lists reconstruct in the wrong order, invalidating thinking-block
|
||||||
|
signatures on replay. See agent/transports/anthropic.py.
|
||||||
|
"""
|
||||||
|
pd = self.provider_data or {}
|
||||||
|
return pd.get("anthropic_content_blocks")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def codex_reasoning_items(self):
|
def codex_reasoning_items(self):
|
||||||
pd = self.provider_data or {}
|
pd = self.provider_data or {}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
|
|||||||
|
|
||||||
_ZERO = Decimal("0")
|
_ZERO = Decimal("0")
|
||||||
_ONE_MILLION = Decimal("1000000")
|
_ONE_MILLION = Decimal("1000000")
|
||||||
|
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||||
|
|
||||||
CostStatus = Literal["actual", "estimated", "included", "unknown"]
|
CostStatus = Literal["actual", "estimated", "included", "unknown"]
|
||||||
CostSource = Literal[
|
CostSource = Literal[
|
||||||
@@ -570,6 +571,8 @@ def resolve_billing_route(
|
|||||||
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
|
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
|
||||||
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
|
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
|
||||||
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
|
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
|
||||||
|
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
|
||||||
|
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
|
||||||
if provider_name == "anthropic":
|
if provider_name == "anthropic":
|
||||||
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||||
if provider_name == "openai":
|
if provider_name == "openai":
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"tauri:build": "tauri build",
|
"tauri:build": "tauri build",
|
||||||
"tauri:build:debug": "tauri build --debug"
|
"tauri:build:debug": "tauri build --debug",
|
||||||
|
"typecheck": "tsc -p . --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nous-research/ui": "0.16.0",
|
"@nous-research/ui": "0.16.0",
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run fix
|
npm run fix
|
||||||
npm run type-check
|
npm run typecheck
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run test:desktop:all
|
npm run test:desktop:all
|
||||||
```
|
```
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 674 KiB After Width: | Height: | Size: 561 KiB |
@@ -40,6 +40,15 @@ const path = require('node:path')
|
|||||||
const https = require('node:https')
|
const https = require('node:https')
|
||||||
const { spawn } = require('node:child_process')
|
const { spawn } = require('node:child_process')
|
||||||
|
|
||||||
|
const IS_WINDOWS = process.platform === 'win32'
|
||||||
|
|
||||||
|
function hiddenWindowsChildOptions(options = {}) {
|
||||||
|
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return { ...options, windowsHide: true }
|
||||||
|
}
|
||||||
|
|
||||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||||
|
|
||||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||||
@@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||||
|
|
||||||
const child = spawn(ps, fullArgs, {
|
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||||||
// choice rather than re-computing the default.
|
// choice rather than re-computing the default.
|
||||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ const { fileURLToPath, pathToFileURL } = require('node:url')
|
|||||||
const { execFileSync, spawn } = require('node:child_process')
|
const { execFileSync, spawn } = require('node:child_process')
|
||||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||||
|
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||||
|
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||||
|
const {
|
||||||
|
OFFICIAL_REPO_HTTPS_URL,
|
||||||
|
isOfficialSshRemote
|
||||||
|
} = require('./update-remote.cjs')
|
||||||
const {
|
const {
|
||||||
buildPosixCleanupScript,
|
buildPosixCleanupScript,
|
||||||
buildWindowsCleanupScript,
|
buildWindowsCleanupScript,
|
||||||
@@ -38,6 +44,7 @@ const {
|
|||||||
shouldRemoveAppBundle,
|
shouldRemoveAppBundle,
|
||||||
uninstallArgsForMode
|
uninstallArgsForMode
|
||||||
} = require('./desktop-uninstall.cjs')
|
} = require('./desktop-uninstall.cjs')
|
||||||
|
const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
|
||||||
const {
|
const {
|
||||||
authModeFromStatus,
|
authModeFromStatus,
|
||||||
buildGatewayWsUrl,
|
buildGatewayWsUrl,
|
||||||
@@ -62,9 +69,11 @@ const {
|
|||||||
} = require('./hardening.cjs')
|
} = require('./hardening.cjs')
|
||||||
|
|
||||||
let nodePty = null
|
let nodePty = null
|
||||||
|
let nodePtyDir = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
nodePty = require('node-pty')
|
nodePty = require('node-pty')
|
||||||
|
nodePtyDir = path.dirname(require.resolve('node-pty/package.json'))
|
||||||
} catch {
|
} catch {
|
||||||
// Packaged builds set `files:` in package.json, which excludes node_modules
|
// Packaged builds set `files:` in package.json, which excludes node_modules
|
||||||
// from the asar. Workspace dedup also hoists this native dep to the repo
|
// from the asar. Workspace dedup also hoists this native dep to the repo
|
||||||
@@ -77,10 +86,12 @@ try {
|
|||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const resourcesPath = process.resourcesPath
|
const resourcesPath = process.resourcesPath
|
||||||
if (resourcesPath) {
|
if (resourcesPath) {
|
||||||
nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty'))
|
nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty')
|
||||||
|
nodePty = require(nodePtyDir)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
nodePty = null
|
nodePty = null
|
||||||
|
nodePtyDir = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +111,13 @@ const IS_WINDOWS = process.platform === 'win32'
|
|||||||
const IS_WSL = isWslEnvironment()
|
const IS_WSL = isWslEnvironment()
|
||||||
const APP_ROOT = app.getAppPath()
|
const APP_ROOT = app.getAppPath()
|
||||||
|
|
||||||
|
function hiddenWindowsChildOptions(options = {}) {
|
||||||
|
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return { ...options, windowsHide: true }
|
||||||
|
}
|
||||||
|
|
||||||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||||
@@ -1099,7 +1117,7 @@ function findSystemPython() {
|
|||||||
const out = execFileSync(
|
const out = execFileSync(
|
||||||
'reg',
|
'reg',
|
||||||
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
||||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
)
|
)
|
||||||
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
||||||
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
||||||
@@ -1135,10 +1153,10 @@ function findSystemPython() {
|
|||||||
if (pyExe) {
|
if (pyExe) {
|
||||||
for (const version of SUPPORTED_VERSIONS) {
|
for (const version of SUPPORTED_VERSIONS) {
|
||||||
try {
|
try {
|
||||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
|
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
stdio: ['ignore', 'pipe', 'ignore']
|
||||||
})
|
}))
|
||||||
const candidate = out.trim()
|
const candidate = out.trim()
|
||||||
if (candidate && fileExists(candidate)) return candidate
|
if (candidate && fileExists(candidate)) return candidate
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1273,11 +1291,11 @@ function resolveUpdateRoot() {
|
|||||||
|
|
||||||
function runGit(args, options = {}) {
|
function runGit(args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
|
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
|
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
@@ -1298,6 +1316,11 @@ function runGit(args, options = {}) {
|
|||||||
|
|
||||||
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
|
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
|
||||||
|
|
||||||
|
async function getOriginUrl(updateRoot) {
|
||||||
|
const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot })
|
||||||
|
return origin.code === 0 ? origin.stdout.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
function emitUpdateProgress(payload) {
|
function emitUpdateProgress(payload) {
|
||||||
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
|
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
|
||||||
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
|
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
|
||||||
@@ -1317,7 +1340,9 @@ async function resolveHealedBranch(updateRoot, branch) {
|
|||||||
return branch || 'main'
|
return branch || 'main'
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
|
const originUrl = await getOriginUrl(updateRoot)
|
||||||
|
const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin'
|
||||||
|
const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot })
|
||||||
if (probe.code !== 2) {
|
if (probe.code !== 2) {
|
||||||
return branch
|
return branch
|
||||||
}
|
}
|
||||||
@@ -1345,6 +1370,40 @@ async function checkUpdates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
branch = await resolveHealedBranch(updateRoot, branch)
|
branch = await resolveHealedBranch(updateRoot, branch)
|
||||||
|
const originUrl = await getOriginUrl(updateRoot)
|
||||||
|
if (isOfficialSshRemote(originUrl)) {
|
||||||
|
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
||||||
|
const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
|
||||||
|
git(['rev-parse', 'HEAD']),
|
||||||
|
runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
|
||||||
|
git(['status', '--porcelain']),
|
||||||
|
git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||||
|
])
|
||||||
|
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
|
||||||
|
if (target.code !== 0 || !targetSha) {
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
branch,
|
||||||
|
error: 'fetch-failed',
|
||||||
|
message: firstLine(target.stderr) || 'git ls-remote failed.',
|
||||||
|
hermesRoot: updateRoot,
|
||||||
|
fetchedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
branch,
|
||||||
|
currentBranch,
|
||||||
|
behind: currentSha && currentSha === targetSha ? 0 : 1,
|
||||||
|
currentSha,
|
||||||
|
targetSha,
|
||||||
|
commits: [],
|
||||||
|
dirty: dirtyStr.length > 0,
|
||||||
|
hermesRoot: updateRoot,
|
||||||
|
fetchedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
||||||
if (fetched.code !== 0) {
|
if (fetched.code !== 0) {
|
||||||
return {
|
return {
|
||||||
@@ -1487,7 +1546,7 @@ function forceKillProcessTree(pid) {
|
|||||||
if (!IS_WINDOWS) return
|
if (!IS_WINDOWS) return
|
||||||
if (!Number.isInteger(pid) || pid <= 0) return
|
if (!Number.isInteger(pid) || pid <= 0) return
|
||||||
try {
|
try {
|
||||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
|
||||||
} catch {
|
} catch {
|
||||||
// Already gone, or no permission — best effort; the unlock wait below is
|
// Already gone, or no permission — best effort; the unlock wait below is
|
||||||
// the real gate.
|
// the real gate.
|
||||||
@@ -1673,11 +1732,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
|||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let child
|
let child
|
||||||
try {
|
try {
|
||||||
child = spawn(command, args, {
|
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env, ...(env || {}) },
|
env: { ...process.env, ...(env || {}) },
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resolve({ code: 1, error: err.message })
|
resolve({ code: 1, error: err.message })
|
||||||
return
|
return
|
||||||
@@ -1948,6 +2007,21 @@ function resolveRendererIndex() {
|
|||||||
return candidates[0]
|
return candidates[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when `dir` lives inside the packaged app bundle / install tree.
|
||||||
|
// Packaged Electron's process.cwd() (and npm's INIT_CWD when dev tooling
|
||||||
|
// leaked into a release build) often resolve here — e.g. win-unpacked on
|
||||||
|
// Windows — which is exactly where PR #37536 item 16 said we must NOT run.
|
||||||
|
function isPackagedInstallPath(dir) {
|
||||||
|
return isPackagedInstallPathUnderRoots(dir, {
|
||||||
|
isPackaged: IS_PACKAGED,
|
||||||
|
installRoots: [
|
||||||
|
APP_ROOT,
|
||||||
|
path.dirname(process.execPath),
|
||||||
|
resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resolveHermesCwd() {
|
function resolveHermesCwd() {
|
||||||
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
||||||
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
||||||
@@ -1959,7 +2033,7 @@ function resolveHermesCwd() {
|
|||||||
const candidates = [
|
const candidates = [
|
||||||
readDefaultProjectDir(),
|
readDefaultProjectDir(),
|
||||||
process.env.HERMES_DESKTOP_CWD,
|
process.env.HERMES_DESKTOP_CWD,
|
||||||
process.env.INIT_CWD,
|
IS_PACKAGED ? null : process.env.INIT_CWD,
|
||||||
IS_PACKAGED ? null : process.cwd(),
|
IS_PACKAGED ? null : process.cwd(),
|
||||||
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
||||||
app.getPath('home')
|
app.getPath('home')
|
||||||
@@ -1968,12 +2042,37 @@ function resolveHermesCwd() {
|
|||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate) continue
|
if (!candidate) continue
|
||||||
const resolved = path.resolve(String(candidate))
|
const resolved = path.resolve(String(candidate))
|
||||||
|
|
||||||
|
if (isPackagedInstallPath(resolved)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (directoryExists(resolved)) return resolved
|
if (directoryExists(resolved)) return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.getPath('home')
|
return app.getPath('home')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeWorkspaceCwd(cwd) {
|
||||||
|
const trimmed = typeof cwd === 'string' ? cwd.trim() : ''
|
||||||
|
|
||||||
|
if (!trimmed || isPackagedInstallPath(trimmed)) {
|
||||||
|
return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = path.resolve(trimmed)
|
||||||
|
|
||||||
|
if (directoryExists(resolved)) {
|
||||||
|
return { cwd: resolved, sanitized: false }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the resolved default.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
|
||||||
|
}
|
||||||
|
|
||||||
// Persisted "Default project directory" — surfaced as a setting in the
|
// Persisted "Default project directory" — surfaced as a setting in the
|
||||||
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
||||||
// userData so it survives self-updates without bleeding into the new
|
// userData so it survives self-updates without bleeding into the new
|
||||||
@@ -2624,7 +2723,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
|
|||||||
'--raw',
|
'--raw',
|
||||||
url
|
url
|
||||||
]
|
]
|
||||||
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
|
||||||
const chunks = []
|
const chunks = []
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
|
|
||||||
@@ -3270,14 +3369,18 @@ function setAndPersistZoomLevel(window, zoomLevel) {
|
|||||||
const next = clampZoomLevel(zoomLevel)
|
const next = clampZoomLevel(zoomLevel)
|
||||||
window.webContents.setZoomLevel(next)
|
window.webContents.setZoomLevel(next)
|
||||||
window.webContents
|
window.webContents
|
||||||
.executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`)
|
.executeJavaScript(
|
||||||
|
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
|
||||||
|
)
|
||||||
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
|
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
function restorePersistedZoomLevel(window) {
|
function restorePersistedZoomLevel(window) {
|
||||||
if (!window || window.isDestroyed()) return
|
if (!window || window.isDestroyed()) return
|
||||||
window.webContents
|
window.webContents
|
||||||
.executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`)
|
.executeJavaScript(
|
||||||
|
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
|
||||||
|
)
|
||||||
.then(stored => {
|
.then(stored => {
|
||||||
if (stored == null || !window || window.isDestroyed()) return
|
if (stored == null || !window || window.isDestroyed()) return
|
||||||
const level = clampZoomLevel(Number(stored))
|
const level = clampZoomLevel(Number(stored))
|
||||||
@@ -4136,9 +4239,7 @@ async function requestJsonForProfile(profile, path, method, body) {
|
|||||||
const conn = await ensureBackend(profile)
|
const conn = await ensureBackend(profile)
|
||||||
const url = `${conn.baseUrl}${path}`
|
const url = `${conn.baseUrl}${path}`
|
||||||
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
||||||
return conn.authMode === 'oauth'
|
return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts)
|
||||||
? fetchJsonViaOauthSession(url, opts)
|
|
||||||
: fetchJson(url, conn.token, opts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeRemoteAuthMode(rawUrl) {
|
async function probeRemoteAuthMode(rawUrl) {
|
||||||
@@ -4212,7 +4313,8 @@ async function testDesktopConnectionConfig(input = {}) {
|
|||||||
// The block under test: a per-profile entry or the global remote. Coerce has
|
// The block under test: a per-profile entry or the global remote. Coerce has
|
||||||
// already normalized the URL and resolved token inheritance for the scope.
|
// already normalized the URL and resolved token inheritance for the scope.
|
||||||
const block = key ? config.profiles?.[key] || null : config.remote
|
const block = key ? config.profiles?.[key] || null : config.remote
|
||||||
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
|
const wantRemote =
|
||||||
|
block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
|
||||||
// ``/api/status`` is public on every gateway (no creds needed), so a
|
// ``/api/status`` is public on every gateway (no creds needed), so a
|
||||||
// reachability test works for local, token, and oauth modes alike — we only
|
// reachability test works for local, token, and oauth modes alike — we only
|
||||||
// need a base URL. For a remote config we normalize the URL from the input;
|
// need a base URL. For a remote config we normalize the URL from the input;
|
||||||
@@ -4295,20 +4397,31 @@ async function teardownPrimaryBackendAndWait() {
|
|||||||
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
||||||
resetHermesConnection()
|
resetHermesConnection()
|
||||||
|
|
||||||
if (!dying) {
|
await waitForBackendExit(dying)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForBackendExit(child, timeoutMs = 5000) {
|
||||||
|
if (!child) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
dying.kill('SIGKILL')
|
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||||
|
forceKillProcessTree(child.pid)
|
||||||
|
} else {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Already gone.
|
// Already gone.
|
||||||
}
|
}
|
||||||
resolve()
|
resolve()
|
||||||
}, 5000)
|
}, timeoutMs)
|
||||||
dying.once('exit', () => {
|
child.once('exit', () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
@@ -4430,12 +4543,16 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
|
|
||||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||||
|
|
||||||
const child = spawn(backend.command, backend.args, {
|
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||||
cwd: hermesCwd,
|
cwd: hermesCwd,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
HERMES_HOME,
|
HERMES_HOME,
|
||||||
...backend.env,
|
...backend.env,
|
||||||
|
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
||||||
|
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||||
|
// can still point at the install dir even when spawn cwd is home.
|
||||||
|
TERMINAL_CWD: hermesCwd,
|
||||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||||
// scheduler tick loop (the gateway isn't running under the app).
|
// scheduler tick loop (the gateway isn't running under the app).
|
||||||
@@ -4444,7 +4561,7 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
},
|
},
|
||||||
shell: backend.shell,
|
shell: backend.shell,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
entry.process = child
|
entry.process = child
|
||||||
entry.port = port
|
entry.port = port
|
||||||
entry.token = token
|
entry.token = token
|
||||||
@@ -4466,7 +4583,9 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||||
backendPool.delete(profile)
|
backendPool.delete(profile)
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`))
|
rejectStart?.(
|
||||||
|
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4500,12 +4619,70 @@ function stopPoolBackend(profile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function teardownPoolBackendAndWait(profile) {
|
||||||
|
const entry = backendPool.get(profile)
|
||||||
|
if (!entry) return
|
||||||
|
backendPool.delete(profile)
|
||||||
|
|
||||||
|
if (entry.process && !entry.process.killed) {
|
||||||
|
try {
|
||||||
|
entry.process.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForBackendExit(entry.process)
|
||||||
|
}
|
||||||
|
|
||||||
function stopAllPoolBackends() {
|
function stopAllPoolBackends() {
|
||||||
for (const profile of [...backendPool.keys()]) {
|
for (const profile of [...backendPool.keys()]) {
|
||||||
stopPoolBackend(profile)
|
stopPoolBackend(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileNameFromDeleteRequest(request) {
|
||||||
|
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = ''
|
||||||
|
try {
|
||||||
|
raw = decodeURIComponent(match[1])
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = raw.trim()
|
||||||
|
if (!name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() === 'default') {
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
return name.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareProfileDeleteRequest(request) {
|
||||||
|
const profile = profileNameFromDeleteRequest(request)
|
||||||
|
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile === primaryProfileKey()) {
|
||||||
|
writeActiveDesktopProfile('default')
|
||||||
|
await teardownPrimaryBackendAndWait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await teardownPoolBackendAndWait(profile)
|
||||||
|
}
|
||||||
|
|
||||||
async function startHermes() {
|
async function startHermes() {
|
||||||
// Latched-failure short-circuit: once bootstrap has failed in this
|
// Latched-failure short-circuit: once bootstrap has failed in this
|
||||||
// process, every subsequent startHermes() call re-throws the same error
|
// process, every subsequent startHermes() call re-throws the same error
|
||||||
@@ -4566,7 +4743,7 @@ async function startHermes() {
|
|||||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||||
|
|
||||||
hermesProcess = spawn(backend.command, backend.args, {
|
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||||
cwd: hermesCwd,
|
cwd: hermesCwd,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -4580,6 +4757,7 @@ async function startHermes() {
|
|||||||
// can't reliably do that, so we set it inline for every spawn.
|
// can't reliably do that, so we set it inline for every spawn.
|
||||||
HERMES_HOME,
|
HERMES_HOME,
|
||||||
...backend.env,
|
...backend.env,
|
||||||
|
TERMINAL_CWD: hermesCwd,
|
||||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||||
// scheduler tick loop (the gateway isn't running under the app).
|
// scheduler tick loop (the gateway isn't running under the app).
|
||||||
@@ -4588,7 +4766,7 @@ async function startHermes() {
|
|||||||
},
|
},
|
||||||
shell: backend.shell,
|
shell: backend.shell,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
|
|
||||||
hermesProcess.stdout.on('data', rememberLog)
|
hermesProcess.stdout.on('data', rememberLog)
|
||||||
hermesProcess.stderr.on('data', rememberLog)
|
hermesProcess.stderr.on('data', rememberLog)
|
||||||
@@ -4677,6 +4855,94 @@ async function startHermes() {
|
|||||||
return connectionPromise
|
return connectionPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared navigation guards + window chrome wiring applied to every window
|
||||||
|
// (the primary plus any secondary session windows). Factored out of
|
||||||
|
// createWindow() so secondary windows can't drift from the main window's
|
||||||
|
// security posture: external links open in the OS browser, in-app navigation
|
||||||
|
// stays confined to the dev server / packaged file URL, and the preview /
|
||||||
|
// devtools / zoom / context-menu affordances behave identically everywhere.
|
||||||
|
function wireCommonWindowHandlers(win) {
|
||||||
|
installPreviewShortcut(win)
|
||||||
|
installDevToolsShortcut(win)
|
||||||
|
installZoomShortcuts(win)
|
||||||
|
installContextMenu(win)
|
||||||
|
win.webContents.setWindowOpenHandler(details => {
|
||||||
|
openExternalUrl(details.url)
|
||||||
|
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
win.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
openExternalUrl(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary "session windows" — one extra OS window per chat so a user can
|
||||||
|
// work with multiple chats side by side. The registry guarantees one window
|
||||||
|
// per sessionId (re-opening focuses the existing window) and self-cleans on
|
||||||
|
// close. The primary mainWindow is never tracked here. Pure logic + the URL
|
||||||
|
// builder live in session-windows.cjs so they stay unit-testable.
|
||||||
|
const sessionWindows = createSessionWindowRegistry()
|
||||||
|
|
||||||
|
function focusWindow(win) {
|
||||||
|
if (!win || win.isDestroyed()) return
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
if (!win.isVisible()) win.show()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open (or focus) a standalone window for a single chat session.
|
||||||
|
function createSessionWindow(sessionId) {
|
||||||
|
return sessionWindows.openOrFocus(sessionId, () => {
|
||||||
|
const icon = getAppIconPath()
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 480,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 420,
|
||||||
|
minHeight: 620,
|
||||||
|
title: 'Hermes',
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||||
|
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||||
|
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||||
|
icon,
|
||||||
|
backgroundColor: '#f7f7f7',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
webviewTag: true,
|
||||||
|
sandbox: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
devTools: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (IS_MAC) {
|
||||||
|
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||||
|
}
|
||||||
|
|
||||||
|
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||||
|
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||||
|
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||||
|
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||||
|
|
||||||
|
wireCommonWindowHandlers(win)
|
||||||
|
|
||||||
|
win.loadURL(
|
||||||
|
buildSessionWindowUrl(sessionId, {
|
||||||
|
devServer: DEV_SERVER,
|
||||||
|
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return win
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const icon = getAppIconPath()
|
const icon = getAppIconPath()
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -4737,23 +5003,7 @@ function createWindow() {
|
|||||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||||
|
|
||||||
installPreviewShortcut(mainWindow)
|
wireCommonWindowHandlers(mainWindow)
|
||||||
installDevToolsShortcut(mainWindow)
|
|
||||||
installZoomShortcuts(mainWindow)
|
|
||||||
installContextMenu(mainWindow)
|
|
||||||
mainWindow.webContents.setWindowOpenHandler(details => {
|
|
||||||
openExternalUrl(details.url)
|
|
||||||
|
|
||||||
return { action: 'deny' }
|
|
||||||
})
|
|
||||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
|
||||||
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
openExternalUrl(url)
|
|
||||||
})
|
|
||||||
|
|
||||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||||
@@ -4859,6 +5109,15 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
||||||
|
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
|
||||||
|
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
||||||
|
return { ok: false, error: 'invalid-session-id' }
|
||||||
|
}
|
||||||
|
|
||||||
|
createSessionWindow(sessionId.trim())
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||||
// reset connection state so the next startHermes() call restarts the
|
// reset connection state so the next startHermes() call restarts the
|
||||||
@@ -5097,17 +5356,19 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
|
|||||||
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
|
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
|
||||||
|
|
||||||
// Swap each remote profile's stale local rows/total for the remote's real ones.
|
// Swap each remote profile's stale local rows/total for the remote's real ones.
|
||||||
await Promise.all(remoteProfiles.map(async name => {
|
await Promise.all(
|
||||||
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
remoteProfiles.map(async name => {
|
||||||
if (!list) {
|
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
||||||
delete profileTotals[name] // dead remote → drop its stale local total too
|
if (!list) {
|
||||||
return
|
delete profileTotals[name] // dead remote → drop its stale local total too
|
||||||
}
|
return
|
||||||
const rows = rowsOf(list)
|
}
|
||||||
merged.push(...rows)
|
const rows = rowsOf(list)
|
||||||
profileTotals[name] = Number(list.total) || rows.length
|
merged.push(...rows)
|
||||||
total += profileTotals[name]
|
profileTotals[name] = Number(list.total) || rows.length
|
||||||
}))
|
total += profileTotals[name]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
||||||
merged.sort((a, b) => recency(b) - recency(a))
|
merged.sort((a, b) => recency(b) - recency(a))
|
||||||
@@ -5124,6 +5385,8 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||||||
return rerouted
|
return rerouted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prepareProfileDeleteRequest(request)
|
||||||
|
|
||||||
const connection = await ensureBackend(request?.profile)
|
const connection = await ensureBackend(request?.profile)
|
||||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||||
const url = `${connection.baseUrl}${request.path}`
|
const url = `${connection.baseUrl}${request.path}`
|
||||||
@@ -5271,9 +5534,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
|||||||
// session spawn (no app restart needed).
|
// session spawn (no app restart needed).
|
||||||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||||||
dir: readDefaultProjectDir(),
|
dir: readDefaultProjectDir(),
|
||||||
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
|
defaultLabel: app.getPath('home'),
|
||||||
|
resolvedCwd: resolveHermesCwd()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
|
||||||
|
|
||||||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||||||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||||||
|
|
||||||
@@ -5363,22 +5629,121 @@ function findGitRoot(start) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalShellCommand() {
|
function isExecutableFile(filePath) {
|
||||||
if (IS_WINDOWS) {
|
if (!filePath || !path.isAbsolute(filePath)) {
|
||||||
return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredShell = process.env.SHELL || ''
|
try {
|
||||||
const shellPath =
|
fs.accessSync(filePath, fs.constants.X_OK)
|
||||||
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
|
return true
|
||||||
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
|
} catch {
|
||||||
'/bin/sh'
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function posixShellSpec(shellPath) {
|
||||||
const shellName = path.basename(shellPath)
|
const shellName = path.basename(shellPath)
|
||||||
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
||||||
|
|
||||||
return { args: interactiveArgs, command: shellPath, name: shellName }
|
return { args: interactiveArgs, command: shellPath, name: shellName }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let spawnHelperChecked = false
|
||||||
|
|
||||||
|
// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a
|
||||||
|
// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the
|
||||||
|
// staged copy under resources/native-deps) loses its execute bit through npm
|
||||||
|
// pack / electron-builder file collection, so every nodePty.spawn() dies with
|
||||||
|
// "posix_spawnp failed". Restore +x once, lazily, before the first spawn.
|
||||||
|
function ensureSpawnHelperExecutable() {
|
||||||
|
if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnHelperChecked = true
|
||||||
|
|
||||||
|
const arch = process.arch
|
||||||
|
const candidates = [
|
||||||
|
path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'),
|
||||||
|
path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const helper of candidates) {
|
||||||
|
try {
|
||||||
|
const mode = fs.statSync(helper).mode
|
||||||
|
|
||||||
|
if ((mode & 0o111) !== 0o111) {
|
||||||
|
fs.chmodSync(helper, mode | 0o755)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not present in this layout (e.g. compiled build vs prebuild); skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
|
||||||
|
// prefer it only after PowerShell 7+ (`pwsh`).
|
||||||
|
function windowsPowerShellPath() {
|
||||||
|
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
|
||||||
|
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||||
|
|
||||||
|
return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a resolved shell path to its spawn spec, picking interactive flags by
|
||||||
|
// family: PowerShell drops its logo banner (so the prompt sits flush like the
|
||||||
|
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
|
||||||
|
// gets POSIX interactive-login flags.
|
||||||
|
function shellSpecFor(shellPath) {
|
||||||
|
const name = path.basename(shellPath).toLowerCase()
|
||||||
|
|
||||||
|
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
|
||||||
|
return { args: ['-NoLogo'], command: shellPath, name }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith('cmd')) {
|
||||||
|
return { args: [], command: shellPath, name }
|
||||||
|
}
|
||||||
|
|
||||||
|
return posixShellSpec(shellPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
|
||||||
|
// 5.1, then comspec/cmd.exe as the universal fallback.
|
||||||
|
function windowsShellSpec() {
|
||||||
|
const command =
|
||||||
|
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
|
||||||
|
|
||||||
|
return shellSpecFor(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the interactive shell for the embedded terminal: an explicit user
|
||||||
|
// override wins, otherwise auto-detect the best one installed for the platform.
|
||||||
|
function terminalShellCommand() {
|
||||||
|
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
|
||||||
|
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
|
||||||
|
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
|
||||||
|
// node-pty can't spawn natively.
|
||||||
|
const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim()
|
||||||
|
|
||||||
|
if (override) {
|
||||||
|
const resolved = isExecutableFile(override) ? override : findOnPath(override)
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
return shellSpecFor(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
return windowsShellSpec()
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
|
||||||
|
|
||||||
|
return posixShellSpec(shellPath || '/bin/sh')
|
||||||
|
}
|
||||||
|
|
||||||
function safeTerminalCwd(cwd) {
|
function safeTerminalCwd(cwd) {
|
||||||
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
||||||
|
|
||||||
@@ -5416,6 +5781,11 @@ function terminalShellEnv() {
|
|||||||
env.TERM_PROGRAM = 'Hermes'
|
env.TERM_PROGRAM = 'Hermes'
|
||||||
env.TERM_PROGRAM_VERSION = app.getVersion()
|
env.TERM_PROGRAM_VERSION = app.getVersion()
|
||||||
|
|
||||||
|
// Let a hermes/--tui launched in this pane know it's embedded in the desktop
|
||||||
|
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
|
||||||
|
// which marks the agent *backend* and gates cron/gateway behavior.
|
||||||
|
env.HERMES_DESKTOP_TERMINAL = '1'
|
||||||
|
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5487,6 +5857,8 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
|||||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureSpawnHelperExecutable()
|
||||||
|
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const { args, command, name } = terminalShellCommand()
|
const { args, command, name } = terminalShellCommand()
|
||||||
const cwd = safeTerminalCwd(payload?.cwd)
|
const cwd = safeTerminalCwd(payload?.cwd)
|
||||||
@@ -5666,11 +6038,11 @@ async function getUninstallSummary() {
|
|||||||
resolve(value)
|
resolve(value)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
|
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||||
cwd: agentRoot,
|
cwd: agentRoot,
|
||||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
stdio: ['ignore', 'pipe', 'ignore']
|
||||||
})
|
}))
|
||||||
child.stdout.on('data', chunk => {
|
child.stdout.on('data', chunk => {
|
||||||
stdout += chunk.toString()
|
stdout += chunk.toString()
|
||||||
})
|
})
|
||||||
@@ -5809,6 +6181,12 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
|||||||
return runDesktopUninstall(String(mode || ''))
|
return runDesktopUninstall(String(mode || ''))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Download a VS Code Marketplace extension and return the raw color-theme JSON
|
||||||
|
// it contributes. No theme code is executed — we only read JSON from the .vsix.
|
||||||
|
ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
|
||||||
|
|
||||||
|
// Search the Marketplace for color-theme extensions (empty query = top installs).
|
||||||
|
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
if (IS_MAC) {
|
if (IS_MAC) {
|
||||||
@@ -5824,7 +6202,14 @@ app.whenReady().then(() => {
|
|||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
// Recreate the primary window if it's gone. Guard on mainWindow directly
|
||||||
|
// (not just total window count) so a dock click still restores the main
|
||||||
|
// window when only secondary session windows remain open.
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
createWindow()
|
||||||
|
} else {
|
||||||
|
focusWindow(mainWindow)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
||||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||||
|
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
|
||||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||||
@@ -41,6 +42,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||||
|
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
|
||||||
settings: {
|
settings: {
|
||||||
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
||||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||||
@@ -132,5 +134,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
ipcRenderer.on('hermes:updates:progress', listener)
|
ipcRenderer.on('hermes:updates:progress', listener)
|
||||||
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
|
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
themes: {
|
||||||
|
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
|
||||||
|
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
86
apps/desktop/electron/session-windows.cjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Secondary "session windows" — one extra OS window per chat so a user can
|
||||||
|
// work with multiple chats side by side. The pure, Electron-free pieces live
|
||||||
|
// here so they can be unit-tested with node --test (mirroring how the rest of
|
||||||
|
// electron/*.cjs splits testable logic out of the main.cjs monolith).
|
||||||
|
|
||||||
|
const { pathToFileURL } = require('node:url')
|
||||||
|
|
||||||
|
// Build the renderer URL for a secondary window. The renderer uses a
|
||||||
|
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||||
|
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||||
|
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||||
|
// renderer reads the flag from window.location.search to suppress the install /
|
||||||
|
// onboarding overlays and the global session sidebar.
|
||||||
|
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
|
||||||
|
const route = `#/${encodeURIComponent(sessionId)}`
|
||||||
|
|
||||||
|
if (devServer) {
|
||||||
|
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||||
|
|
||||||
|
return `${base}/?win=secondary${route}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||||
|
// opening a session that already has a live window focuses it instead of
|
||||||
|
// spawning a duplicate, and a window removes itself from the registry when it
|
||||||
|
// closes. The actual BrowserWindow construction is injected (the `factory`) so
|
||||||
|
// this module stays free of Electron and is unit-testable.
|
||||||
|
function createSessionWindowRegistry() {
|
||||||
|
const windows = new Map()
|
||||||
|
|
||||||
|
function openOrFocus(sessionId, factory) {
|
||||||
|
const key = typeof sessionId === 'string' ? sessionId.trim() : ''
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = windows.get(key)
|
||||||
|
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
// Focus-or-create: never duplicate a window for the same chat.
|
||||||
|
if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
|
||||||
|
existing.restore?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
|
||||||
|
existing.show?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.focus?.()
|
||||||
|
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = factory(key)
|
||||||
|
|
||||||
|
if (!win) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
windows.set(key, win)
|
||||||
|
|
||||||
|
// Self-cleanup on close so the registry never holds a destroyed window.
|
||||||
|
win.on?.('closed', () => {
|
||||||
|
if (windows.get(key) === win) {
|
||||||
|
windows.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openOrFocus,
|
||||||
|
get: key => windows.get(key),
|
||||||
|
has: key => windows.has(key),
|
||||||
|
get size() {
|
||||||
|
return windows.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
|
||||||
165
apps/desktop/electron/session-windows.test.cjs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const test = require('node:test')
|
||||||
|
|
||||||
|
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||||
|
|
||||||
|
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||||
|
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||||
|
// registry actually touches.
|
||||||
|
function makeFakeWindow() {
|
||||||
|
const listeners = {}
|
||||||
|
const calls = { focus: 0, show: 0, restore: 0 }
|
||||||
|
let destroyed = false
|
||||||
|
let minimized = false
|
||||||
|
let visible = true
|
||||||
|
|
||||||
|
return {
|
||||||
|
on(event, handler) {
|
||||||
|
listeners[event] = handler
|
||||||
|
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
emit(event) {
|
||||||
|
listeners[event]?.()
|
||||||
|
},
|
||||||
|
isDestroyed: () => destroyed,
|
||||||
|
destroy() {
|
||||||
|
destroyed = true
|
||||||
|
},
|
||||||
|
isMinimized: () => minimized,
|
||||||
|
setMinimized(value) {
|
||||||
|
minimized = value
|
||||||
|
},
|
||||||
|
isVisible: () => visible,
|
||||||
|
setVisible(value) {
|
||||||
|
visible = value
|
||||||
|
},
|
||||||
|
restore() {
|
||||||
|
calls.restore += 1
|
||||||
|
minimized = false
|
||||||
|
},
|
||||||
|
show() {
|
||||||
|
calls.show += 1
|
||||||
|
visible = true
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
calls.focus += 1
|
||||||
|
},
|
||||||
|
calls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildSessionWindowUrl puts the secondary flag before the hash route (dev server)', () => {
|
||||||
|
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173' })
|
||||||
|
|
||||||
|
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildSessionWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
|
||||||
|
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173/' })
|
||||||
|
|
||||||
|
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildSessionWindowUrl encodes the session id in the hash route', () => {
|
||||||
|
const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' })
|
||||||
|
|
||||||
|
// The query flag must precede the '#' or HashRouter would swallow it as the
|
||||||
|
// route; the id is URL-encoded so slashes/spaces survive routeSessionId().
|
||||||
|
assert.equal(url, 'http://localhost:5173/?win=secondary#/a%20b%2Fc')
|
||||||
|
assert.ok(url.indexOf('?win=secondary') < url.indexOf('#'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildSessionWindowUrl builds a packaged file URL with the flag before the hash', () => {
|
||||||
|
const url = buildSessionWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
|
||||||
|
|
||||||
|
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registry opens one window per session and focuses on re-open', () => {
|
||||||
|
const registry = createSessionWindowRegistry()
|
||||||
|
let built = 0
|
||||||
|
const win = makeFakeWindow()
|
||||||
|
const factory = () => {
|
||||||
|
built += 1
|
||||||
|
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = registry.openOrFocus('s1', factory)
|
||||||
|
const second = registry.openOrFocus('s1', factory)
|
||||||
|
|
||||||
|
assert.equal(built, 1, 'factory runs once for the same session')
|
||||||
|
assert.equal(first, second)
|
||||||
|
assert.equal(registry.size, 1)
|
||||||
|
assert.equal(win.calls.focus, 1, 'second open focuses the existing window')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registry restores + shows a minimized/hidden window on re-open', () => {
|
||||||
|
const registry = createSessionWindowRegistry()
|
||||||
|
const win = makeFakeWindow()
|
||||||
|
registry.openOrFocus('s1', () => win)
|
||||||
|
|
||||||
|
win.setMinimized(true)
|
||||||
|
win.setVisible(false)
|
||||||
|
registry.openOrFocus('s1', () => win)
|
||||||
|
|
||||||
|
assert.equal(win.calls.restore, 1)
|
||||||
|
assert.equal(win.calls.show, 1)
|
||||||
|
assert.equal(win.calls.focus, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registry drops the entry when the window closes', () => {
|
||||||
|
const registry = createSessionWindowRegistry()
|
||||||
|
const win = makeFakeWindow()
|
||||||
|
registry.openOrFocus('s1', () => win)
|
||||||
|
assert.equal(registry.size, 1)
|
||||||
|
|
||||||
|
win.emit('closed')
|
||||||
|
|
||||||
|
assert.equal(registry.size, 0)
|
||||||
|
assert.equal(registry.has('s1'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registry rebuilds a fresh window after the previous one was destroyed', () => {
|
||||||
|
const registry = createSessionWindowRegistry()
|
||||||
|
const first = makeFakeWindow()
|
||||||
|
registry.openOrFocus('s1', () => first)
|
||||||
|
first.destroy()
|
||||||
|
|
||||||
|
let built = 0
|
||||||
|
const second = makeFakeWindow()
|
||||||
|
const result = registry.openOrFocus('s1', () => {
|
||||||
|
built += 1
|
||||||
|
|
||||||
|
return second
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(built, 1, 'a destroyed window is replaced, not focused')
|
||||||
|
assert.equal(result, second)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registry ignores empty / non-string session ids', () => {
|
||||||
|
const registry = createSessionWindowRegistry()
|
||||||
|
let built = 0
|
||||||
|
const factory = () => {
|
||||||
|
built += 1
|
||||||
|
|
||||||
|
return makeFakeWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(registry.openOrFocus('', factory), null)
|
||||||
|
assert.equal(registry.openOrFocus(' ', factory), null)
|
||||||
|
assert.equal(registry.openOrFocus(null, factory), null)
|
||||||
|
assert.equal(registry.openOrFocus(42, factory), null)
|
||||||
|
assert.equal(built, 0)
|
||||||
|
assert.equal(registry.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('registry trims the session id before keying', () => {
|
||||||
|
const registry = createSessionWindowRegistry()
|
||||||
|
const win = makeFakeWindow()
|
||||||
|
registry.openOrFocus(' s1 ', () => win)
|
||||||
|
|
||||||
|
assert.equal(registry.has('s1'), true)
|
||||||
|
})
|
||||||
56
apps/desktop/electron/update-remote.cjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for choosing a remote URL during passive update checks.
|
||||||
|
*
|
||||||
|
* A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`.
|
||||||
|
* If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch
|
||||||
|
* origin` triggers an unexplained hardware-touch prompt. For passive checks
|
||||||
|
* against the official repo we substitute the public HTTPS `ls-remote` path,
|
||||||
|
* which needs no auth and cannot prompt. Active update/apply flows are left
|
||||||
|
* unchanged.
|
||||||
|
*
|
||||||
|
* Extracted from main.cjs so the security-critical remote detection is unit
|
||||||
|
* testable without booting Electron (main.cjs requires('electron') at load).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
|
||||||
|
const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
|
||||||
|
|
||||||
|
// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased,
|
||||||
|
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
|
||||||
|
// compare equal.
|
||||||
|
function canonicalGitHubRemote(url) {
|
||||||
|
if (!url) return ''
|
||||||
|
let value = String(url).trim()
|
||||||
|
if (value.startsWith('git@github.com:')) {
|
||||||
|
value = `github.com/${value.slice('git@github.com:'.length)}`
|
||||||
|
} else if (value.startsWith('ssh://git@github.com/')) {
|
||||||
|
value = `github.com/${value.slice('ssh://git@github.com/'.length)}`
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value)
|
||||||
|
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
|
||||||
|
} catch {
|
||||||
|
// Leave non-URL forms unchanged.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value = value.trim().replace(/\/+$/, '')
|
||||||
|
if (value.endsWith('.git')) value = value.slice(0, -4)
|
||||||
|
return value.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSshRemote(url) {
|
||||||
|
const value = String(url || '').trim().toLowerCase()
|
||||||
|
return value.startsWith('git@') || value.startsWith('ssh://')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOfficialSshRemote(url) {
|
||||||
|
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
OFFICIAL_REPO_HTTPS_URL,
|
||||||
|
OFFICIAL_REPO_CANONICAL,
|
||||||
|
canonicalGitHubRemote,
|
||||||
|
isSshRemote,
|
||||||
|
isOfficialSshRemote
|
||||||
|
}
|
||||||
78
apps/desktop/electron/update-remote.test.cjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Tests for electron/update-remote.cjs — the remote-detection helpers that
|
||||||
|
* keep passive update checks off the SSH origin for official installs.
|
||||||
|
*
|
||||||
|
* Run with: node --test electron/update-remote.test.cjs
|
||||||
|
* (Wired into npm test:desktop:platforms in package.json.)
|
||||||
|
*
|
||||||
|
* Why this matters: a public install can carry
|
||||||
|
* origin=git@github.com:NousResearch/hermes-agent.git. A background
|
||||||
|
* `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey
|
||||||
|
* key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote
|
||||||
|
* must reliably recognize the official SSH remote (in every URL form,
|
||||||
|
* case-insensitively) so the caller can swap in the anonymous HTTPS path —
|
||||||
|
* while NOT misclassifying forks, other hosts, or the HTTPS remote (which
|
||||||
|
* never prompts and should keep the normal fetch path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const {
|
||||||
|
OFFICIAL_REPO_HTTPS_URL,
|
||||||
|
OFFICIAL_REPO_CANONICAL,
|
||||||
|
canonicalGitHubRemote,
|
||||||
|
isSshRemote,
|
||||||
|
isOfficialSshRemote
|
||||||
|
} = require('./update-remote.cjs')
|
||||||
|
|
||||||
|
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
|
||||||
|
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||||
|
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL)
|
||||||
|
assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||||
|
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||||
|
// Case-insensitive: an uppercased owner still canonicalizes to the same repo.
|
||||||
|
assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||||
|
// Trailing slashes are stripped.
|
||||||
|
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canonicalGitHubRemote is empty for falsy input', () => {
|
||||||
|
assert.equal(canonicalGitHubRemote(''), '')
|
||||||
|
assert.equal(canonicalGitHubRemote(null), '')
|
||||||
|
assert.equal(canonicalGitHubRemote(undefined), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isSshRemote detects scp-like and ssh:// forms only', () => {
|
||||||
|
assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||||
|
assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||||
|
assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||||
|
assert.equal(isSshRemote(''), false)
|
||||||
|
assert.equal(isSshRemote(null), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isOfficialSshRemote is true only for the official repo over SSH', () => {
|
||||||
|
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||||
|
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true)
|
||||||
|
assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||||
|
// Case-insensitive owner/repo match.
|
||||||
|
assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => {
|
||||||
|
// A fork over SSH belongs to the user — fetching it is their own remote,
|
||||||
|
// not the official upstream, so the SSH-avoidance swap must not apply.
|
||||||
|
assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false)
|
||||||
|
// Same repo name on a different host is not the official repo.
|
||||||
|
assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false)
|
||||||
|
// HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the
|
||||||
|
// normal fetch path — must not be flagged as an official SSH remote.
|
||||||
|
assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||||
|
assert.equal(isOfficialSshRemote(''), false)
|
||||||
|
assert.equal(isOfficialSshRemote(null), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => {
|
||||||
|
// Invariant: the URL we substitute in must be the same repo we detect.
|
||||||
|
assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL)
|
||||||
|
})
|
||||||
331
apps/desktop/electron/vscode-marketplace.cjs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS Code Marketplace color-theme fetcher (main process).
|
||||||
|
*
|
||||||
|
* Resolves an extension's latest version via the (undocumented but stable)
|
||||||
|
* gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the
|
||||||
|
* color-theme JSON files it contributes. No theme code is ever executed — we
|
||||||
|
* only read `package.json` + the referenced `*.json` theme files out of the
|
||||||
|
* archive and hand their text back to the renderer to convert.
|
||||||
|
*
|
||||||
|
* Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central
|
||||||
|
* directory and inflate just the entries we need with `zlib`. Avoids pulling a
|
||||||
|
* zip library into the desktop bundle for a feature this small.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('node:https')
|
||||||
|
const zlib = require('node:zlib')
|
||||||
|
|
||||||
|
const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
|
||||||
|
const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
|
||||||
|
const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.
|
||||||
|
const MAX_REDIRECTS = 5
|
||||||
|
const REQUEST_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
|
const ID_RE = /^[\w-]+\.[\w-]+$/
|
||||||
|
|
||||||
|
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
|
||||||
|
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(url, { method, headers }, res => {
|
||||||
|
const status = res.statusCode ?? 0
|
||||||
|
|
||||||
|
if (status >= 300 && status < 400 && res.headers.location) {
|
||||||
|
if (redirectsLeft <= 0) {
|
||||||
|
res.resume()
|
||||||
|
reject(new Error('Too many redirects.'))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new URL(res.headers.location, url).toString()
|
||||||
|
res.resume()
|
||||||
|
// Redirects to the CDN are plain GETs (drop the POST body).
|
||||||
|
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
res.resume()
|
||||||
|
reject(new Error(`Request failed (${status}) for ${url}`))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = []
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
res.on('data', chunk => {
|
||||||
|
total += chunk.length
|
||||||
|
|
||||||
|
if (total > maxBytes) {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('Response exceeded the size limit.'))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', reject)
|
||||||
|
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.')))
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
req.write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */
|
||||||
|
async function resolveExtension(id) {
|
||||||
|
const json = await queryGallery({
|
||||||
|
// FilterType 7 = ExtensionName (the full publisher.extension id).
|
||||||
|
filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }],
|
||||||
|
// Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri |
|
||||||
|
// IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
|
||||||
|
flags: 914
|
||||||
|
})
|
||||||
|
const extension = json?.results?.[0]?.extensions?.[0]
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
throw new Error(`Extension "${id}" was not found on the Marketplace.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = extension.versions?.[0]
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
throw new Error(`Extension "${id}" has no published versions.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE)
|
||||||
|
const vsixUrl = asset?.source
|
||||||
|
|
||||||
|
if (!vsixUrl) {
|
||||||
|
throw new Error(`Could not find a downloadable package for "${id}".`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { displayName: extension.displayName || id, vsixUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST an ExtensionQuery payload and return the parsed gallery response. */
|
||||||
|
async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
const raw = await request(GALLERY_QUERY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json;api-version=3.0-preview.1',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'User-Agent': 'Hermes-Desktop'
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
maxBytes
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSON.parse(raw.toString('utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the Marketplace for color-theme extensions. With an empty query this
|
||||||
|
* returns the most-installed themes; with a query it's a full-text search
|
||||||
|
* scoped to the Themes category. Returns lightweight cards (no download).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The "Themes" category also contains file-icon and product-icon themes (the
|
||||||
|
* gallery has no color-only category). We can't see an extension's actual
|
||||||
|
* contributions without downloading it, so filter the obvious icon packs out by
|
||||||
|
* tag + name/description. Color themes that also ship icons are rare; worst case
|
||||||
|
* a user installs them by exact id from settings.
|
||||||
|
*/
|
||||||
|
function looksLikeIconTheme(extension) {
|
||||||
|
const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase())
|
||||||
|
|
||||||
|
if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase()
|
||||||
|
|
||||||
|
return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMarketplaceThemes(query, limit = 20) {
|
||||||
|
const text = String(query || '').trim()
|
||||||
|
const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50)
|
||||||
|
|
||||||
|
// FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags.
|
||||||
|
const criteria = [
|
||||||
|
{ filterType: 8, value: 'Microsoft.VisualStudio.Code' },
|
||||||
|
{ filterType: 5, value: 'Themes' },
|
||||||
|
{ filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000).
|
||||||
|
]
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
criteria.push({ filterType: 10, value: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await queryGallery({
|
||||||
|
// Over-fetch so the icon-theme filter below still leaves a full page.
|
||||||
|
filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }],
|
||||||
|
// IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4).
|
||||||
|
flags: 772
|
||||||
|
})
|
||||||
|
|
||||||
|
const extensions = json?.results?.[0]?.extensions ?? []
|
||||||
|
|
||||||
|
return extensions
|
||||||
|
.filter(extension => !looksLikeIconTheme(extension))
|
||||||
|
.slice(0, pageSize)
|
||||||
|
.map(extension => {
|
||||||
|
const publisherName = extension.publisher?.publisherName ?? ''
|
||||||
|
const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install')
|
||||||
|
|
||||||
|
return {
|
||||||
|
extensionId: `${publisherName}.${extension.extensionName}`,
|
||||||
|
displayName: extension.displayName || extension.extensionName,
|
||||||
|
publisher: extension.publisher?.displayName || publisherName,
|
||||||
|
description: extension.shortDescription || '',
|
||||||
|
installs: Math.round(installStat?.value ?? 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Minimal zip reader ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function findEndOfCentralDirectory(buf) {
|
||||||
|
// EOCD signature 0x06054b50, scanning back from the end (comment is rare).
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) {
|
||||||
|
if (buf.readUInt32LE(i) === 0x06054b50) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Not a valid zip archive (no end-of-central-directory).')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the central directory into a name → record map. */
|
||||||
|
function readCentralDirectory(buf) {
|
||||||
|
const eocd = findEndOfCentralDirectory(buf)
|
||||||
|
const count = buf.readUInt16LE(eocd + 10)
|
||||||
|
let offset = buf.readUInt32LE(eocd + 16)
|
||||||
|
const records = new Map()
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (buf.readUInt32LE(offset) !== 0x02014b50) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = buf.readUInt16LE(offset + 10)
|
||||||
|
const compressedSize = buf.readUInt32LE(offset + 20)
|
||||||
|
const nameLen = buf.readUInt16LE(offset + 28)
|
||||||
|
const extraLen = buf.readUInt16LE(offset + 30)
|
||||||
|
const commentLen = buf.readUInt16LE(offset + 32)
|
||||||
|
const localOffset = buf.readUInt32LE(offset + 42)
|
||||||
|
const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)
|
||||||
|
|
||||||
|
records.set(name, { method, compressedSize, localOffset })
|
||||||
|
offset += 46 + nameLen + extraLen + commentLen
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inflate a single entry to a string. */
|
||||||
|
function extractEntry(buf, record) {
|
||||||
|
// The local header's name/extra lengths can differ from the central record,
|
||||||
|
// so re-read them here to locate the compressed payload.
|
||||||
|
if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {
|
||||||
|
throw new Error('Corrupt zip: bad local file header.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameLen = buf.readUInt16LE(record.localOffset + 26)
|
||||||
|
const extraLen = buf.readUInt16LE(record.localOffset + 28)
|
||||||
|
const dataStart = record.localOffset + 30 + nameLen + extraLen
|
||||||
|
const data = buf.subarray(dataStart, dataStart + record.compressedSize)
|
||||||
|
|
||||||
|
// 0 = stored, 8 = deflate. Theme files are one or the other.
|
||||||
|
return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a package.json theme path to its zip entry name. */
|
||||||
|
function themeEntryName(themePath) {
|
||||||
|
const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '')
|
||||||
|
|
||||||
|
return `extension/${clean}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract every contributed color theme from a `.vsix` buffer. */
|
||||||
|
function extractThemes(vsixBuffer) {
|
||||||
|
const records = readCentralDirectory(vsixBuffer)
|
||||||
|
const pkgRecord = records.get('extension/package.json')
|
||||||
|
|
||||||
|
if (!pkgRecord) {
|
||||||
|
throw new Error('Package manifest missing from the extension.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord))
|
||||||
|
const contributed = pkg?.contributes?.themes
|
||||||
|
|
||||||
|
if (!Array.isArray(contributed) || contributed.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes = []
|
||||||
|
|
||||||
|
for (const entry of contributed) {
|
||||||
|
if (!entry?.path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = records.get(themeEntryName(entry.path))
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
themes.push({
|
||||||
|
label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',
|
||||||
|
uiTheme: entry.uiTheme,
|
||||||
|
contents: extractEntry(vsixBuffer, record)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Skip an entry we can't inflate rather than failing the whole install.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return themes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public entry: resolve, download, and extract color themes for `id`
|
||||||
|
* (`publisher.extension`). Returns `{ extensionId, displayName, themes }`.
|
||||||
|
*/
|
||||||
|
async function fetchMarketplaceThemes(id) {
|
||||||
|
const trimmed = String(id || '').trim()
|
||||||
|
|
||||||
|
if (!ID_RE.test(trimmed)) {
|
||||||
|
throw new Error('Expected a Marketplace id like "publisher.extension".')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { displayName, vsixUrl } = await resolveExtension(trimmed)
|
||||||
|
const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } })
|
||||||
|
const themes = extractThemes(vsix)
|
||||||
|
|
||||||
|
return { extensionId: trimmed, displayName, themes }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchMarketplaceThemes,
|
||||||
|
searchMarketplaceThemes,
|
||||||
|
extractThemes,
|
||||||
|
readCentralDirectory,
|
||||||
|
__testing: { themeEntryName, looksLikeIconTheme }
|
||||||
|
}
|
||||||
113
apps/desktop/electron/vscode-marketplace.test.cjs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const assert = require('node:assert')
|
||||||
|
const test = require('node:test')
|
||||||
|
|
||||||
|
const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
|
||||||
|
|
||||||
|
// Build a minimal zip with stored (uncompressed) entries so the test controls
|
||||||
|
// the bytes exactly — exercises the central-directory reader + theme extraction
|
||||||
|
// without a deflate dependency.
|
||||||
|
function makeZip(entries) {
|
||||||
|
const locals = []
|
||||||
|
const centrals = []
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (const { name, data } of entries) {
|
||||||
|
const nameBuf = Buffer.from(name, 'utf8')
|
||||||
|
const body = Buffer.from(data, 'utf8')
|
||||||
|
|
||||||
|
const local = Buffer.alloc(30 + nameBuf.length)
|
||||||
|
local.writeUInt32LE(0x04034b50, 0)
|
||||||
|
local.writeUInt16LE(0, 8) // method: stored
|
||||||
|
local.writeUInt32LE(body.length, 18) // compressed size
|
||||||
|
local.writeUInt32LE(body.length, 22) // uncompressed size
|
||||||
|
local.writeUInt16LE(nameBuf.length, 26)
|
||||||
|
nameBuf.copy(local, 30)
|
||||||
|
|
||||||
|
locals.push(local, body)
|
||||||
|
|
||||||
|
const central = Buffer.alloc(46 + nameBuf.length)
|
||||||
|
central.writeUInt32LE(0x02014b50, 0)
|
||||||
|
central.writeUInt16LE(0, 10) // method: stored
|
||||||
|
central.writeUInt32LE(body.length, 20)
|
||||||
|
central.writeUInt32LE(body.length, 24)
|
||||||
|
central.writeUInt16LE(nameBuf.length, 28)
|
||||||
|
central.writeUInt32LE(offset, 42) // local header offset
|
||||||
|
nameBuf.copy(central, 46)
|
||||||
|
|
||||||
|
centrals.push(central)
|
||||||
|
offset += local.length + body.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralStart = offset
|
||||||
|
const centralBuf = Buffer.concat(centrals)
|
||||||
|
|
||||||
|
const eocd = Buffer.alloc(22)
|
||||||
|
eocd.writeUInt32LE(0x06054b50, 0)
|
||||||
|
eocd.writeUInt16LE(entries.length, 8)
|
||||||
|
eocd.writeUInt16LE(entries.length, 10)
|
||||||
|
eocd.writeUInt32LE(centralBuf.length, 12)
|
||||||
|
eocd.writeUInt32LE(centralStart, 16)
|
||||||
|
|
||||||
|
return Buffer.concat([...locals, centralBuf, eocd])
|
||||||
|
}
|
||||||
|
|
||||||
|
test('readCentralDirectory finds every entry', () => {
|
||||||
|
const zip = makeZip([
|
||||||
|
{ name: 'extension/package.json', data: '{}' },
|
||||||
|
{ name: 'extension/themes/x.json', data: '{}' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const records = readCentralDirectory(zip)
|
||||||
|
assert.ok(records.has('extension/package.json'))
|
||||||
|
assert.ok(records.has('extension/themes/x.json'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extractThemes reads contributed color themes (resolving ./ paths)', () => {
|
||||||
|
const pkg = JSON.stringify({
|
||||||
|
name: 'theme-dracula',
|
||||||
|
displayName: 'Dracula',
|
||||||
|
contributes: {
|
||||||
|
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
|
||||||
|
|
||||||
|
const zip = makeZip([
|
||||||
|
{ name: 'extension/package.json', data: pkg },
|
||||||
|
{ name: 'extension/themes/dracula.json', data: themeJson }
|
||||||
|
])
|
||||||
|
|
||||||
|
const themes = extractThemes(zip)
|
||||||
|
assert.strictEqual(themes.length, 1)
|
||||||
|
assert.strictEqual(themes[0].label, 'Dracula')
|
||||||
|
assert.strictEqual(themes[0].uiTheme, 'vs-dark')
|
||||||
|
assert.match(themes[0].contents, /editor\.background/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extractThemes returns empty when the extension contributes no themes', () => {
|
||||||
|
const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }])
|
||||||
|
assert.deepStrictEqual(extractThemes(zip), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extractThemes throws when the manifest is missing', () => {
|
||||||
|
const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }])
|
||||||
|
assert.throws(() => extractThemes(zip), /manifest missing/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => {
|
||||||
|
const { looksLikeIconTheme } = __testing
|
||||||
|
|
||||||
|
// Tagged contribution points are the strongest signal.
|
||||||
|
assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true)
|
||||||
|
assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true)
|
||||||
|
|
||||||
|
// Name/description fallback for packs that don't tag themselves.
|
||||||
|
assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true)
|
||||||
|
assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true)
|
||||||
|
|
||||||
|
// Real color themes survive.
|
||||||
|
assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false)
|
||||||
|
assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false)
|
||||||
|
})
|
||||||
54
apps/desktop/electron/windows-child-process.test.cjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const ELECTRON_DIR = __dirname
|
||||||
|
|
||||||
|
function readElectronFile(name) {
|
||||||
|
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireHiddenChildOptions(source, needle) {
|
||||||
|
const index = source.indexOf(needle)
|
||||||
|
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||||
|
const snippet = source.slice(index, index + 700)
|
||||||
|
assert.match(
|
||||||
|
snippet,
|
||||||
|
/hiddenWindowsChildOptions\(/,
|
||||||
|
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('desktop background child processes opt into hidden Windows consoles', () => {
|
||||||
|
const source = readElectronFile('main.cjs')
|
||||||
|
|
||||||
|
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||||
|
|
||||||
|
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||||
|
requireHiddenChildOptions(source, 'execFileSync(pyExe')
|
||||||
|
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
|
||||||
|
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||||
|
requireHiddenChildOptions(source, 'spawn(command, args')
|
||||||
|
requireHiddenChildOptions(source, "spawn('curl'")
|
||||||
|
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
|
||||||
|
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
|
||||||
|
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('intentional or interactive desktop child processes stay documented', () => {
|
||||||
|
const source = readElectronFile('main.cjs')
|
||||||
|
|
||||||
|
assert.match(source, /windowsHide: false/)
|
||||||
|
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||||
|
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||||
|
const source = readElectronFile('bootstrap-runner.cjs')
|
||||||
|
|
||||||
|
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||||
|
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
|
||||||
|
})
|
||||||
38
apps/desktop/electron/workspace-cwd.cjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
/** True when `dir` lives inside a packaged app bundle / install tree. */
|
||||||
|
function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
|
||||||
|
if (!isPackaged || !dir) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolved = path.resolve(String(dir))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = new Set(
|
||||||
|
(installRoots ?? [])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(candidate => path.resolve(String(candidate)))
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
if (resolved === root) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const rel = path.relative(root, resolved)
|
||||||
|
|
||||||
|
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isPackagedInstallPath }
|
||||||
45
apps/desktop/electron/workspace-cwd.test.cjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Tests for electron/workspace-cwd.cjs.
|
||||||
|
*
|
||||||
|
* Run with: node --test electron/workspace-cwd.test.cjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
|
||||||
|
|
||||||
|
const installRoot = path.resolve('/opt/Hermes')
|
||||||
|
|
||||||
|
test('isPackagedInstallPath returns false when not packaged', () => {
|
||||||
|
assert.equal(
|
||||||
|
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isPackagedInstallPath flags the install root itself', () => {
|
||||||
|
assert.equal(
|
||||||
|
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isPackagedInstallPath flags paths nested under the install root', () => {
|
||||||
|
const nested = path.join(installRoot, 'resources', 'app.asar')
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isPackagedInstallPath ignores paths outside the install root', () => {
|
||||||
|
const homeProject = path.resolve('/home/user/projects/demo')
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -3,7 +3,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
|||||||
import typescriptParser from '@typescript-eslint/parser'
|
import typescriptParser from '@typescript-eslint/parser'
|
||||||
import perfectionist from 'eslint-plugin-perfectionist'
|
import perfectionist from 'eslint-plugin-perfectionist'
|
||||||
import reactPlugin from 'eslint-plugin-react'
|
import reactPlugin from 'eslint-plugin-react'
|
||||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
|
||||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||||
import unusedImports from 'eslint-plugin-unused-imports'
|
import unusedImports from 'eslint-plugin-unused-imports'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
@@ -47,7 +46,6 @@ export default [
|
|||||||
'custom-rules': customRules,
|
'custom-rules': customRules,
|
||||||
perfectionist,
|
perfectionist,
|
||||||
react: reactPlugin,
|
react: reactPlugin,
|
||||||
'react-compiler': reactCompiler,
|
|
||||||
'react-hooks': hooksPlugin,
|
'react-hooks': hooksPlugin,
|
||||||
'unused-imports': unusedImports
|
'unused-imports': unusedImports
|
||||||
},
|
},
|
||||||
@@ -98,7 +96,6 @@ export default [
|
|||||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||||
'react-compiler/react-compiler': 'warn',
|
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'unused-imports/no-unused-imports': 'error'
|
'unused-imports/no-unused-imports': 'error'
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
"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-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
|
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.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/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||||
"type-check": "tsc -b",
|
"typecheck": "tsc -p . --noEmit",
|
||||||
"lint": "eslint src/ electron/",
|
"lint": "eslint src/ electron/",
|
||||||
"lint:fix": "eslint src/ electron/ --fix",
|
"lint:fix": "eslint src/ electron/ --fix",
|
||||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
|
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
|
||||||
@@ -103,20 +103,19 @@
|
|||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||||
"@typescript-eslint/parser": "^8.59.1",
|
"@typescript-eslint/parser": "^8.59.1",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^10.0.3",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^40.9.3",
|
"electron": "^40.9.3",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-perfectionist": "^5.9.0",
|
"eslint-plugin-perfectionist": "^5.9.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 528 KiB |
@@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react'
|
|||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
|
||||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { ComposerAttachment } from '@/store/composer'
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||||
@@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||||||
const c = t.composer
|
const c = t.composer
|
||||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||||
const cwd = useStore($currentCwd)
|
const cwd = useStore($currentCwd)
|
||||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
const isUploading = attachment.uploadState === 'uploading'
|
||||||
|
const hasUploadError = attachment.uploadState === 'error'
|
||||||
|
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||||
|
|
||||||
async function openPreview() {
|
async function openPreview() {
|
||||||
@@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||||||
throw new Error(c.couldNotPreview(attachment.label))
|
throw new Error(c.couldNotPreview(attachment.label))
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
// We already hold the image bytes (the card thumbnail) — render those
|
||||||
|
// directly so a screenshot/clipboard image previews even when its only
|
||||||
|
// on-disk copy is a transient path the renderer can't re-read.
|
||||||
|
const withBytes =
|
||||||
|
attachment.kind === 'image' && attachment.previewUrl
|
||||||
|
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
|
||||||
|
: preview
|
||||||
|
|
||||||
|
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, c.previewUnavailable)
|
notifyError(error, c.previewUnavailable)
|
||||||
}
|
}
|
||||||
@@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||||
<div className="group/attachment relative min-w-0 shrink-0">
|
<div className="group/attachment relative min-w-0 shrink-0">
|
||||||
<button
|
<button
|
||||||
|
aria-busy={isUploading || undefined}
|
||||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
className={cn(
|
||||||
|
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
|
||||||
|
hasUploadError
|
||||||
|
? 'border-destructive/45 hover:border-destructive/60'
|
||||||
|
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||||
|
)}
|
||||||
disabled={!canPreview}
|
disabled={!canPreview}
|
||||||
onClick={() => void openPreview()}
|
onClick={() => void openPreview()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||||
<img
|
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||||
alt={attachment.label}
|
<img
|
||||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
alt={attachment.label}
|
||||||
draggable={false}
|
className="size-full object-cover"
|
||||||
src={attachment.previewUrl}
|
draggable={false}
|
||||||
/>
|
src={attachment.previewUrl}
|
||||||
) : (
|
/>
|
||||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
) : (
|
||||||
<Icon className="size-3.5" />
|
<Icon className="size-3.5" />
|
||||||
</span>
|
)}
|
||||||
)}
|
{isUploading && (
|
||||||
|
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasUploadError && (
|
||||||
|
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
|
||||||
|
<AlertCircle className="size-3.5 text-destructive" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||||
{attachment.label}
|
{attachment.label}
|
||||||
</span>
|
</span>
|
||||||
{detail && (
|
{detail && (
|
||||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block truncate text-[0.62rem] leading-3.5',
|
||||||
|
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{detail}
|
{detail}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,32 +3,25 @@ import { ComposerPrimitive } from '@assistant-ui/react'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export const COMPLETION_DRAWER_CLASS = [
|
export const COMPLETION_DRAWER_CLASS = [
|
||||||
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
|
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
|
||||||
'w-60 max-w-[calc(100vw-2rem)]',
|
'w-80 max-w-[calc(100vw-2rem)]',
|
||||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||||
'p-1 text-xs text-popover-foreground shadow-md',
|
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||||
'backdrop-blur-md'
|
'backdrop-blur-md'
|
||||||
].join(' ')
|
].join(' ')
|
||||||
|
|
||||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||||
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
|
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
|
||||||
'w-60 max-w-[calc(100vw-2rem)]',
|
'w-80 max-w-[calc(100vw-2rem)]',
|
||||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||||
'p-1 text-xs text-popover-foreground shadow-md',
|
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||||
'backdrop-blur-md'
|
'backdrop-blur-md'
|
||||||
].join(' ')
|
].join(' ')
|
||||||
|
|
||||||
export const COMPLETION_DRAWER_ROW_CLASS = [
|
|
||||||
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
|
|
||||||
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
|
|
||||||
'hover:bg-(--ui-bg-tertiary)',
|
|
||||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
|
||||||
].join(' ')
|
|
||||||
|
|
||||||
export function ComposerCompletionDrawer({
|
export function ComposerCompletionDrawer({
|
||||||
adapter,
|
adapter,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
||||||
|
import { formatCombo } from '@/lib/keybinds/combo'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||||
@@ -62,6 +63,7 @@ export function ComposerControls({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const c = t.composer
|
const c = t.composer
|
||||||
|
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
|
||||||
|
|
||||||
if (conversation.active) {
|
if (conversation.active) {
|
||||||
return <ConversationPill {...conversation} disabled={disabled} />
|
return <ConversationPill {...conversation} disabled={disabled} />
|
||||||
@@ -73,9 +75,9 @@ export function ComposerControls({
|
|||||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||||
{canSteer && (
|
{canSteer && (
|
||||||
<Tip label={c.steer}>
|
<Tip label={steerLabel}>
|
||||||
<Button
|
<Button
|
||||||
aria-label={c.steer}
|
aria-label={steerLabel}
|
||||||
className={GHOST_ICON_BTN}
|
className={GHOST_ICON_BTN}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onSteer}
|
onClick={onSteer}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// No global setupFiles registers auto-cleanup, so unmount between tests —
|
||||||
|
// otherwise a second render() leaks the first editor and getByTestId('editor')
|
||||||
|
// matches multiple nodes.
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
|
||||||
|
// branch + submitDraft), driven through REAL DOM keydown events on a
|
||||||
|
// contentEditable.
|
||||||
|
//
|
||||||
|
// Regression repro for #39630: pressing Enter right after typing (fast typing /
|
||||||
|
// IME) did nothing. The composer state (`draft` from useAuiState) and its
|
||||||
|
// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
|
||||||
|
// read empty state and either dropped the message, drained a queued prompt
|
||||||
|
// instead of sending, or (while busy) refused to queue. The fix reads the live
|
||||||
|
// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
|
||||||
|
// submitDraft — so the just-typed text always wins.
|
||||||
|
//
|
||||||
|
// We model the race deterministically the way the IME repro does: mutate the
|
||||||
|
// editor's textContent WITHOUT firing an input event, so the React `draft`
|
||||||
|
// state stays stale while the DOM already holds the text.
|
||||||
|
function Harness({
|
||||||
|
busy = false,
|
||||||
|
queued = [],
|
||||||
|
onSubmit,
|
||||||
|
onQueue,
|
||||||
|
onCancel,
|
||||||
|
onDrain
|
||||||
|
}: {
|
||||||
|
busy?: boolean
|
||||||
|
queued?: readonly string[]
|
||||||
|
onSubmit: (text: string) => void
|
||||||
|
onQueue: (text: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
onDrain: () => void
|
||||||
|
}) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
|
const draftRef = useRef('')
|
||||||
|
// Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
|
||||||
|
// it lags the DOM until React re-renders (the source of the bug).
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const attachments: unknown[] = []
|
||||||
|
|
||||||
|
const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
|
||||||
|
|
||||||
|
const setText = (next: string) => {
|
||||||
|
draftRef.current = next
|
||||||
|
setDraft(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDraft = () => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (editor) {
|
||||||
|
const domText = composerPlainText(editor)
|
||||||
|
if (domText !== draftRef.current) {
|
||||||
|
draftRef.current = domText
|
||||||
|
setDraft(domText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = draftRef.current
|
||||||
|
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
if (payloadPresent) {
|
||||||
|
onQueue(text)
|
||||||
|
} else {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
} else if (!payloadPresent && queued.length > 0) {
|
||||||
|
onDrain()
|
||||||
|
} else if (payloadPresent) {
|
||||||
|
onSubmit(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||||
|
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||||
|
|
||||||
|
if (!busy && !hasLivePayload && queued.length > 0) {
|
||||||
|
onDrain()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busy && !hasLivePayload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitDraft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `draft` is read so the lint/compiler treats the stale-state mirror as live;
|
||||||
|
// the assertions prove the handler never relies on it.
|
||||||
|
void draft
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
contentEditable
|
||||||
|
data-testid="editor"
|
||||||
|
onInput={event => setText(composerPlainText(event.currentTarget))}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
ref={editorRef}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
|
||||||
|
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
|
||||||
|
)
|
||||||
|
const editor = getByTestId('editor')
|
||||||
|
|
||||||
|
// Fast typing: the DOM has the text but NO input event fired, so `draft`
|
||||||
|
// state is still empty (the exact stale-state race).
|
||||||
|
await act(async () => {
|
||||||
|
editor.textContent = 'hello world'
|
||||||
|
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
|
||||||
|
const onQueue = vi.fn()
|
||||||
|
const onDrain = vi.fn()
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
|
||||||
|
)
|
||||||
|
const editor = getByTestId('editor')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editor.textContent = 'urgent follow-up'
|
||||||
|
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
|
||||||
|
expect(onDrain).not.toHaveBeenCalled()
|
||||||
|
expect(onCancel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const onQueue = vi.fn()
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
|
||||||
|
)
|
||||||
|
const editor = getByTestId('editor')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editor.textContent = ''
|
||||||
|
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onCancel).not.toHaveBeenCalled()
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
expect(onQueue).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
|
||||||
|
const onDrain = vi.fn()
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||||
|
)
|
||||||
|
const editor = getByTestId('editor')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editor.textContent = ''
|
||||||
|
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onDrain).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,13 @@ export interface CompletionEntry {
|
|||||||
text: string
|
text: string
|
||||||
display?: unknown
|
display?: unknown
|
||||||
meta?: unknown
|
meta?: unknown
|
||||||
|
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
|
||||||
|
* header whenever this changes between consecutive items, so the fetcher must
|
||||||
|
* emit entries already grouped contiguously. */
|
||||||
|
group?: string
|
||||||
|
/** Optional completion-action id. When set, picking the item runs that action
|
||||||
|
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
|
||||||
|
action?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompletionPayload {
|
export interface CompletionPayload {
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import type { HermesGateway } from '@/hermes'
|
import type { HermesGateway } from '@/hermes'
|
||||||
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
import {
|
import {
|
||||||
type CommandsCatalogLike,
|
type CommandsCatalogLike,
|
||||||
|
desktopSkinSlashCompletions,
|
||||||
desktopSlashDescription,
|
desktopSlashDescription,
|
||||||
|
type DesktopThemeCommandOption,
|
||||||
filterDesktopCommandsCatalog,
|
filterDesktopCommandsCatalog,
|
||||||
|
isDesktopSlashExtensionCommand,
|
||||||
isDesktopSlashSuggestion
|
isDesktopSlashSuggestion
|
||||||
} from '@/lib/desktop-slash-commands'
|
} from '@/lib/desktop-slash-commands'
|
||||||
|
import { $sessions } from '@/store/session'
|
||||||
|
|
||||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||||
@@ -16,7 +21,10 @@ interface SlashItemMetadata extends Record<string, string> {
|
|||||||
command: string
|
command: string
|
||||||
display: string
|
display: string
|
||||||
meta: string
|
meta: string
|
||||||
|
group: string
|
||||||
rawText: string
|
rawText: string
|
||||||
|
/** Completion-action id; empty for ordinary insert-a-chip completions. */
|
||||||
|
action: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function textValue(value: unknown, fallback = ''): string {
|
function textValue(value: unknown, fallback = ''): string {
|
||||||
@@ -38,12 +46,21 @@ function commandText(value: string): string {
|
|||||||
return value.startsWith('/') ? value : `/${value}`
|
return value.startsWith('/') ? value : `/${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many recent sessions to surface inline before the "Browse all…" entry. */
|
||||||
|
const SESSION_INLINE_LIMIT = 7
|
||||||
|
|
||||||
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
||||||
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
|
export function useSlashCompletions(options: {
|
||||||
|
gateway: HermesGateway | null
|
||||||
|
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
|
||||||
|
* come from here, not the backend (whose skin list is CLI/TUI-only). */
|
||||||
|
skinThemes?: DesktopThemeCommandOption[]
|
||||||
|
activeSkin?: string
|
||||||
|
}): {
|
||||||
adapter: Unstable_TriggerAdapter
|
adapter: Unstable_TriggerAdapter
|
||||||
loading: boolean
|
loading: boolean
|
||||||
} {
|
} {
|
||||||
const { gateway } = options
|
const { gateway, skinThemes, activeSkin } = options
|
||||||
const enabled = Boolean(gateway)
|
const enabled = Boolean(gateway)
|
||||||
|
|
||||||
const fetcher = useCallback(
|
const fetcher = useCallback(
|
||||||
@@ -54,34 +71,136 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||||||
|
|
||||||
const text = `/${query}`
|
const text = `/${query}`
|
||||||
|
|
||||||
|
// The desktop owns /skin entirely (client-side theme context). Surface its
|
||||||
|
// theme list inside this single popover instead of a bespoke one, and skip
|
||||||
|
// the backend skin completions (which describe CLI/TUI skins that don't
|
||||||
|
// apply here). Matches once we're past `/skin ` into the arg stage.
|
||||||
|
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
|
||||||
|
|
||||||
|
if (skinArg && skinThemes) {
|
||||||
|
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
|
||||||
|
text: entry.text,
|
||||||
|
display: entry.display,
|
||||||
|
meta: entry.meta,
|
||||||
|
group: 'Themes'
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { items, query }
|
||||||
|
}
|
||||||
|
|
||||||
|
// /resume (and its aliases) completes recent sessions inline — the same
|
||||||
|
// client-side list the picker overlay shows — instead of the backend
|
||||||
|
// (whose /resume opens an interactive TUI picker we can't render here).
|
||||||
|
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
|
||||||
|
|
||||||
|
if (sessionArg) {
|
||||||
|
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const matches = (
|
||||||
|
needle
|
||||||
|
? $sessions.get().filter(
|
||||||
|
session =>
|
||||||
|
sessionTitle(session).toLowerCase().includes(needle) ||
|
||||||
|
(session.preview ?? '').toLowerCase().includes(needle) ||
|
||||||
|
session.id.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
: $sessions.get()
|
||||||
|
).slice(0, SESSION_INLINE_LIMIT)
|
||||||
|
|
||||||
|
const items: CompletionEntry[] = matches.map(session => ({
|
||||||
|
text: `/resume ${session.id}`,
|
||||||
|
display: sessionTitle(session),
|
||||||
|
meta: (session.preview ?? '').trim(),
|
||||||
|
group: 'Sessions'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Trailing "more" affordance (Cursor-style): picking it opens the full
|
||||||
|
// session picker overlay directly. `text` stays a bare `/resume` so that
|
||||||
|
// submitting it (Enter) still opens the overlay if the action is skipped.
|
||||||
|
items.push({
|
||||||
|
text: '/resume',
|
||||||
|
display: 'Browse all sessions…',
|
||||||
|
meta: '',
|
||||||
|
group: 'Sessions',
|
||||||
|
action: 'session-picker'
|
||||||
|
})
|
||||||
|
|
||||||
|
return { items, query }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!query) {
|
if (!query) {
|
||||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||||
|
|
||||||
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
|
// Prefer the categorized layout so the popover renders section headers
|
||||||
text: command,
|
// (Session, Tools & Skills, ...). Fall back to the flat list when the
|
||||||
display: command,
|
// backend didn't categorize.
|
||||||
meta
|
const sections = catalog.categories?.length
|
||||||
}))
|
? catalog.categories
|
||||||
|
: [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||||
|
|
||||||
|
const items = sections.flatMap(section =>
|
||||||
|
section.pairs.map(([command, meta]) => ({
|
||||||
|
text: command,
|
||||||
|
display: command,
|
||||||
|
group: section.name || undefined,
|
||||||
|
meta
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
return { items, query }
|
return { items, query }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
|
||||||
|
'complete.slash',
|
||||||
|
{ text }
|
||||||
|
)
|
||||||
|
|
||||||
const items = (result.items ?? [])
|
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||||
|
// with replace_from = 14. Rewrite those entries so the popover inserts
|
||||||
|
// the full `/personality alice` token instead of stranding `/alice`.
|
||||||
|
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
|
||||||
|
const isArgCompletion = replaceFrom > 1
|
||||||
|
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
|
||||||
|
|
||||||
|
const decorated = (result.items ?? [])
|
||||||
|
.map(item => {
|
||||||
|
if (!isArgCompletion) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
const argText = typeof item.text === 'string' ? item.text : ''
|
||||||
|
|
||||||
|
return { ...item, text: `${prefix}${argText}` }
|
||||||
|
})
|
||||||
|
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
// Arg suggestions (e.g. `/handoff <platform>`) live under one
|
||||||
|
// header; otherwise split skills out from built-in commands.
|
||||||
|
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
|
||||||
|
// Arg items carry their own meta (the personality/toolset/platform
|
||||||
|
// blurb). Only command rows get the registry description — looking
|
||||||
|
// one up for `/personality none` would clobber it with the parent
|
||||||
|
// command's text.
|
||||||
|
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Keep each group contiguous so headers render once: Commands before
|
||||||
|
// Skills (stable within a group, preserving backend relevance order).
|
||||||
|
const groupOrder = ['Commands', 'Skills', 'Options']
|
||||||
|
|
||||||
|
const items = isArgCompletion
|
||||||
|
? decorated
|
||||||
|
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||||
|
|
||||||
return { items, query }
|
return { items, query }
|
||||||
} catch {
|
} catch {
|
||||||
return { items: [], query }
|
return { items: [], query }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[gateway]
|
[gateway, skinThemes, activeSkin]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||||
@@ -93,6 +212,8 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||||||
command,
|
command,
|
||||||
display,
|
display,
|
||||||
meta,
|
meta,
|
||||||
|
group: textValue(entry.group),
|
||||||
|
action: textValue(entry.action),
|
||||||
// Provide rawText so hermesDirectiveFormatter.serialize uses the
|
// Provide rawText so hermesDirectiveFormatter.serialize uses the
|
||||||
// direct-insertion path instead of the legacy @type:id fallback.
|
// direct-insertion path instead of the legacy @type:id fallback.
|
||||||
// Without this, the item.id (which includes a "|index" suffix for
|
// Without this, the item.id (which includes a "|index" suffix for
|
||||||
|
|||||||
@@ -13,17 +13,25 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { chatMessageText } from '@/lib/chat-messages'
|
import { chatMessageText } from '@/lib/chat-messages'
|
||||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||||
|
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
|
||||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
import {
|
||||||
|
$composerAttachments,
|
||||||
|
clearComposerAttachments,
|
||||||
|
clearSessionDraft,
|
||||||
|
type ComposerAttachment,
|
||||||
|
stashSessionDraft,
|
||||||
|
takeSessionDraft
|
||||||
|
} from '@/store/composer'
|
||||||
import {
|
import {
|
||||||
browseBackward,
|
browseBackward,
|
||||||
browseForward,
|
browseForward,
|
||||||
@@ -40,10 +48,11 @@ import {
|
|||||||
shouldAutoDrainOnSettle,
|
shouldAutoDrainOnSettle,
|
||||||
updateQueuedPrompt
|
updateQueuedPrompt
|
||||||
} from '@/store/composer-queue'
|
} from '@/store/composer-queue'
|
||||||
import { $gatewayState, $messages } from '@/store/session'
|
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||||
|
import { useTheme } from '@/themes'
|
||||||
|
|
||||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
|
||||||
|
|
||||||
import { AttachmentList } from './attachments'
|
import { AttachmentList } from './attachments'
|
||||||
import { ContextMenu } from './context-menu'
|
import { ContextMenu } from './context-menu'
|
||||||
@@ -64,7 +73,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
|
|||||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||||
import {
|
import {
|
||||||
dragHasAttachments,
|
dragHasAttachments,
|
||||||
droppedFileInlineRef,
|
droppedFileInlineRefs,
|
||||||
type InlineRefInput,
|
type InlineRefInput,
|
||||||
insertInlineRefsIntoEditor
|
insertInlineRefsIntoEditor
|
||||||
} from './inline-refs'
|
} from './inline-refs'
|
||||||
@@ -74,9 +83,9 @@ import {
|
|||||||
placeCaretEnd,
|
placeCaretEnd,
|
||||||
refChipElement,
|
refChipElement,
|
||||||
renderComposerContents,
|
renderComposerContents,
|
||||||
RICH_INPUT_SLOT
|
RICH_INPUT_SLOT,
|
||||||
|
slashChipElement
|
||||||
} from './rich-editor'
|
} from './rich-editor'
|
||||||
import { SkinSlashPopover } from './skin-slash-popover'
|
|
||||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||||
import { ComposerTriggerPopover } from './trigger-popover'
|
import { ComposerTriggerPopover } from './trigger-popover'
|
||||||
import type { ChatBarProps } from './types'
|
import type { ChatBarProps } from './types'
|
||||||
@@ -95,6 +104,30 @@ const COMPOSER_FADE_BACKGROUND =
|
|||||||
|
|
||||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||||
|
|
||||||
|
/** Completion items can carry an `action` (set in use-slash-completions) that
|
||||||
|
* runs a side effect on pick instead of inserting a chip — e.g. the session
|
||||||
|
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
|
||||||
|
* items are a registry row, not a composer branch. */
|
||||||
|
const COMPLETION_ACTIONS: Record<string, () => void> = {
|
||||||
|
'session-picker': () => setSessionPickerOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a picked `/` completion to its pill accent. Driven by the completion
|
||||||
|
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
|
||||||
|
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||||
|
const group = (item.metadata as { group?: unknown } | undefined)?.group
|
||||||
|
|
||||||
|
if (group === 'Skills') {
|
||||||
|
return 'skill'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group === 'Themes') {
|
||||||
|
return 'theme'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'command'
|
||||||
|
}
|
||||||
|
|
||||||
interface QueueEditState {
|
interface QueueEditState {
|
||||||
attachments: ComposerAttachment[]
|
attachments: ComposerAttachment[]
|
||||||
draft: string
|
draft: string
|
||||||
@@ -104,6 +137,10 @@ interface QueueEditState {
|
|||||||
|
|
||||||
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
||||||
|
|
||||||
|
// Quiet period after the last keystroke before persisting the draft;
|
||||||
|
// unmount/pagehide flushes bypass it.
|
||||||
|
const DRAFT_PERSIST_DEBOUNCE_MS = 400
|
||||||
|
|
||||||
export function ChatBar({
|
export function ChatBar({
|
||||||
busy,
|
busy,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -145,6 +182,9 @@ export function ChatBar({
|
|||||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||||
const draftRef = useRef(draft)
|
const draftRef = useRef(draft)
|
||||||
const previousBusyRef = useRef(busy)
|
const previousBusyRef = useRef(busy)
|
||||||
|
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||||
|
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||||
|
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||||
const drainingQueueRef = useRef(false)
|
const drainingQueueRef = useRef(false)
|
||||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
@@ -156,14 +196,17 @@ export function ChatBar({
|
|||||||
const [dragActive, setDragActive] = useState(false)
|
const [dragActive, setDragActive] = useState(false)
|
||||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||||
|
const queueEditRef = useRef(queueEdit)
|
||||||
|
queueEditRef.current = queueEdit
|
||||||
const dragDepthRef = useRef(0)
|
const dragDepthRef = useRef(0)
|
||||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||||
const lastSpokenIdRef = useRef<string | null>(null)
|
const lastSpokenIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||||
|
|
||||||
|
const { availableThemes, themeName } = useTheme()
|
||||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
|
||||||
|
|
||||||
const stacked = expanded || narrow || tight
|
const stacked = expanded || narrow || tight
|
||||||
const trimmedDraft = draft.trim()
|
const trimmedDraft = draft.trim()
|
||||||
@@ -171,10 +214,12 @@ export function ChatBar({
|
|||||||
const canSubmit = busy || hasComposerPayload
|
const canSubmit = busy || hasComposerPayload
|
||||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||||
|
|
||||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||||
// into a tool result) and never for a slash command (those execute inline).
|
// into a tool result) and never for a slash command (those execute inline).
|
||||||
const canSteer =
|
const canSteer =
|
||||||
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
||||||
|
|
||||||
const showHelpHint = draft === '?'
|
const showHelpHint = draft === '?'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -462,12 +507,6 @@ export function ChatBar({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectSkinSlashCommand = (command: string) => {
|
|
||||||
draftRef.current = command
|
|
||||||
aui.composer().setText(command)
|
|
||||||
requestMainFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||||
|
|
||||||
@@ -620,16 +659,50 @@ export function ChatBar({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action items (e.g. "Browse all sessions…") run a side effect instead of
|
||||||
|
// inserting a chip: strip the typed trigger token, then fire the action.
|
||||||
|
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
|
||||||
|
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
|
||||||
|
|
||||||
|
if (runAction) {
|
||||||
|
const current = composerPlainText(editor)
|
||||||
|
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||||
|
|
||||||
|
renderComposerContents(editor, prefix)
|
||||||
|
placeCaretEnd(editor)
|
||||||
|
draftRef.current = composerPlainText(editor)
|
||||||
|
aui.composer().setText(draftRef.current)
|
||||||
|
closeTrigger()
|
||||||
|
runAction()
|
||||||
|
requestMainFocus()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||||
const starter = serialized.endsWith(':')
|
const starter = serialized.endsWith(':')
|
||||||
|
|
||||||
|
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||||
|
// it — expand to its options step so the popover shows the inline list, just
|
||||||
|
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||||
|
// already an arg pick (`/personality alice`), so it commits normally.
|
||||||
|
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||||
|
|
||||||
|
const expandsToArgs =
|
||||||
|
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||||
|
|
||||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||||
|
// No pill while expanding — the bare command stays plain text until an arg
|
||||||
|
// is picked, at which point a single pill is emitted for the full command.
|
||||||
|
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
|
||||||
|
const keepTriggerOpen = starter || expandsToArgs
|
||||||
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
draftRef.current = composerPlainText(editor)
|
draftRef.current = composerPlainText(editor)
|
||||||
aui.composer().setText(draftRef.current)
|
aui.composer().setText(draftRef.current)
|
||||||
requestMainFocus()
|
requestMainFocus()
|
||||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
const sel = window.getSelection()
|
const sel = window.getSelection()
|
||||||
@@ -639,7 +712,20 @@ export function ChatBar({
|
|||||||
|
|
||||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||||
const current = composerPlainText(editor)
|
const current = composerPlainText(editor)
|
||||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||||
|
|
||||||
|
if (slashKind) {
|
||||||
|
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
|
||||||
|
// the platform) land here because the caret sits past a contenteditable
|
||||||
|
// chip. Rebuild the prefix and re-emit a single pill for the full command.
|
||||||
|
renderComposerContents(editor, prefix)
|
||||||
|
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
|
||||||
|
placeCaretEnd(editor)
|
||||||
|
|
||||||
|
return finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderComposerContents(editor, `${prefix}${text}`)
|
||||||
placeCaretEnd(editor)
|
placeCaretEnd(editor)
|
||||||
|
|
||||||
return finish()
|
return finish()
|
||||||
@@ -650,8 +736,13 @@ export function ChatBar({
|
|||||||
replaceRange.setEnd(node, offset)
|
replaceRange.setEnd(node, offset)
|
||||||
replaceRange.deleteContents()
|
replaceRange.deleteContents()
|
||||||
|
|
||||||
if (directive) {
|
const chip = slashKind
|
||||||
const chip = refChipElement(directive[1], directive[2])
|
? slashChipElement(serialized, slashKind)
|
||||||
|
: directive
|
||||||
|
? refChipElement(directive[1], directive[2])
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (chip) {
|
||||||
const space = document.createTextNode(' ')
|
const space = document.createTextNode(' ')
|
||||||
const fragment = document.createDocumentFragment()
|
const fragment = document.createDocumentFragment()
|
||||||
fragment.append(chip, space)
|
fragment.append(chip, space)
|
||||||
@@ -814,7 +905,16 @@ export function ChatBar({
|
|||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
|
// Decide from the DOM, not React state. `hasComposerPayload` is derived
|
||||||
|
// from the AUI composer state, which lags the latest keystroke by a
|
||||||
|
// render, so on fast typing / IME the just-typed text isn't in state yet.
|
||||||
|
// Without the live read, a real message typed while prompts are queued
|
||||||
|
// would drain the queue instead of sending. submitDraft() re-syncs and
|
||||||
|
// sends the live editor text.
|
||||||
|
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||||
|
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||||
|
|
||||||
|
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
|
||||||
void drainNextQueued()
|
void drainNextQueued()
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -822,7 +922,10 @@ export function ChatBar({
|
|||||||
|
|
||||||
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
||||||
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
||||||
if (busy && !hasComposerPayload) {
|
// Gate on the live DOM payload (not the render-lagged composer state) so a
|
||||||
|
// message typed fast / via IME while busy still reaches submitDraft() and
|
||||||
|
// gets queued instead of being mistaken for an empty Enter.
|
||||||
|
if (busy && !hasLivePayload) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,24 +1022,25 @@ export function ChatBar({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
// In-app drags (project tree / gutter) are workspace-relative paths the
|
||||||
const refs = candidates
|
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
// drops are absolute local paths a remote gateway can't read (and images
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
// need byte upload for vision), so route them through the upload pipeline.
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||||
|
const refs = droppedFileInlineRefs(inAppRefs, cwd)
|
||||||
|
|
||||||
if (insertInlineRefs(refs)) {
|
if (refs.length && insertInlineRefs(refs)) {
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
if (osDrops.length) {
|
||||||
if (attached) {
|
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
|
||||||
triggerHaptic('selection')
|
if (attached) {
|
||||||
requestMainFocus()
|
triggerHaptic('selection')
|
||||||
}
|
requestMainFocus()
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||||
@@ -956,11 +1060,7 @@ export function ChatBar({
|
|||||||
|
|
||||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||||
|
|
||||||
const refs = candidates
|
if (!candidates.length) {
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
if (!refs.length) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,9 +1068,27 @@ export function ChatBar({
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
resetDragState()
|
resetDragState()
|
||||||
|
|
||||||
if (insertInlineRefs(refs)) {
|
// Dropping straight onto the text box used to inline-ref *every* file —
|
||||||
|
// including OS/Finder drops, whose absolute local path a remote gateway
|
||||||
|
// can't read and whose image bytes never reached vision. Split by origin:
|
||||||
|
// in-app drags stay inline refs; OS drops go through the upload pipeline.
|
||||||
|
// (When no upload handler is wired, fall back to inline refs for all.)
|
||||||
|
const attach = onAttachDroppedItems
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||||
|
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
|
||||||
|
|
||||||
|
if (refs.length && insertInlineRefs(refs)) {
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attach && osDrops.length) {
|
||||||
|
void Promise.resolve(attach(osDrops)).then(attached => {
|
||||||
|
if (attached) {
|
||||||
|
triggerHaptic('selection')
|
||||||
|
requestMainFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearDraft = useCallback(() => {
|
const clearDraft = useCallback(() => {
|
||||||
@@ -995,6 +1113,69 @@ export function ChatBar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stashAt = (
|
||||||
|
scope: string | null,
|
||||||
|
text = draftRef.current,
|
||||||
|
attachments = $composerAttachments.get()
|
||||||
|
) => stashSessionDraft(scope, text, attachments)
|
||||||
|
|
||||||
|
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||||
|
// never clears composer state; this effect alone stashes on leave, restores
|
||||||
|
// on enter. Keyed writes are idempotent, so no skip-sentinel.
|
||||||
|
useEffect(() => {
|
||||||
|
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
|
||||||
|
loadIntoComposer(text, attachments)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const editing = queueEditRef.current
|
||||||
|
|
||||||
|
if (editing?.sessionKey === activeQueueSessionKey) {
|
||||||
|
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
|
||||||
|
} else if (!isBrowsingHistory(sessionId)) {
|
||||||
|
stashAt(activeQueueSessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Debounced stash into the active scope. Skipped while browsing history or
|
||||||
|
// editing a queued prompt — recalled text must not clobber the real draft.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBrowsingHistory(sessionId) || queueEdit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
|
||||||
|
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
pendingDraftPersistRef.current = null
|
||||||
|
stashAt(activeQueueSessionKey, draft)
|
||||||
|
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(handle)
|
||||||
|
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
|
||||||
|
|
||||||
|
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
|
||||||
|
// inside the debounce window would drop trailing keystrokes without this.
|
||||||
|
useEffect(() => {
|
||||||
|
const flushPendingDraftPersist = () => {
|
||||||
|
const pending = pendingDraftPersistRef.current
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDraftPersistRef.current = null
|
||||||
|
stashAt(pending.scope, pending.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', flushPendingDraftPersist)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', flushPendingDraftPersist)
|
||||||
|
flushPendingDraftPersist()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||||
if (!activeQueueSessionKey || queueEdit) {
|
if (!activeQueueSessionKey || queueEdit) {
|
||||||
return
|
return
|
||||||
@@ -1197,21 +1378,61 @@ export function ChatBar({
|
|||||||
}
|
}
|
||||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||||
|
|
||||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||||
|
// edit snapshot; only restore into the composer when still on the same scope.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queueEdit) {
|
if (!queueEdit) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
|
if (queueEdit.sessionKey === activeQueueSessionKey) {
|
||||||
return
|
if (editingQueuedPrompt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
|
||||||
setQueueEdit(null)
|
setQueueEdit(null)
|
||||||
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||||
|
const submittedScope = activeQueueSessionKeyRef.current
|
||||||
|
const submittedAttachments = attachments ?? []
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
loadIntoComposer(text, submittedAttachments)
|
||||||
|
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
|
||||||
|
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
|
||||||
|
.catch(restore)
|
||||||
|
}
|
||||||
|
|
||||||
const submitDraft = () => {
|
const submitDraft = () => {
|
||||||
|
// Source the text from the DOM editor, not React state. The AUI composer
|
||||||
|
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||||
|
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||||
|
// not have synced yet — reading state here drops the message (Enter looks
|
||||||
|
// like it does nothing; typing a trailing space only "fixes" it because the
|
||||||
|
// extra input event forces a state sync). draftRef is updated on every
|
||||||
|
// input event; refresh it from the editor once more to also cover an
|
||||||
|
// in-flight keystroke that hasn't fired its input event yet.
|
||||||
|
const editor = editorRef.current
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
const domText = composerPlainText(editor)
|
||||||
|
|
||||||
|
if (domText !== draftRef.current) {
|
||||||
|
draftRef.current = domText
|
||||||
|
aui.composer().setText(domText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = draftRef.current
|
||||||
|
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||||
|
|
||||||
if (queueEdit) {
|
if (queueEdit) {
|
||||||
exitQueuedEdit('save')
|
exitQueuedEdit('save')
|
||||||
} else if (busy) {
|
} else if (busy) {
|
||||||
@@ -1222,12 +1443,11 @@ export function ChatBar({
|
|||||||
// busy guard for commands that genuinely need an idle session (skill
|
// busy guard for commands that genuinely need an idle session (skill
|
||||||
// /send directives). Queuing them would make every slash command wait
|
// /send directives). Queuing them would make every slash command wait
|
||||||
// for the current turn to finish, which is how the TUI never behaves.
|
// for the current turn to finish, which is how the TUI never behaves.
|
||||||
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
|
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
|
||||||
const submitted = draft
|
|
||||||
triggerHaptic('submit')
|
triggerHaptic('submit')
|
||||||
clearDraft()
|
clearDraft()
|
||||||
void onSubmit(submitted)
|
dispatchSubmit(text)
|
||||||
} else if (hasComposerPayload) {
|
} else if (payloadPresent) {
|
||||||
queueCurrentDraft()
|
queueCurrentDraft()
|
||||||
} else {
|
} else {
|
||||||
// Stop button (the only way to reach here while busy with an empty
|
// Stop button (the only way to reach here while busy with an empty
|
||||||
@@ -1235,15 +1455,15 @@ export function ChatBar({
|
|||||||
triggerHaptic('cancel')
|
triggerHaptic('cancel')
|
||||||
void Promise.resolve(onCancel())
|
void Promise.resolve(onCancel())
|
||||||
}
|
}
|
||||||
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
|
} else if (!payloadPresent && queuedPrompts.length > 0) {
|
||||||
void drainNextQueued()
|
void drainNextQueued()
|
||||||
} else if (draft.trim() || attachments.length > 0) {
|
} else if (payloadPresent) {
|
||||||
const submitted = draft
|
const submittedAttachments = cloneAttachments(attachments)
|
||||||
triggerHaptic('submit')
|
triggerHaptic('submit')
|
||||||
resetBrowseState(sessionId)
|
resetBrowseState(sessionId)
|
||||||
clearDraft()
|
clearDraft()
|
||||||
clearComposerAttachments()
|
clearComposerAttachments()
|
||||||
void onSubmit(submitted, { attachments })
|
dispatchSubmit(text, submittedAttachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
focusInput()
|
focusInput()
|
||||||
@@ -1468,7 +1688,6 @@ export function ChatBar({
|
|||||||
onPick={replaceTriggerWithChip}
|
onPick={replaceTriggerWithChip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
|
||||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||||
// Out of flow so the queue never inflates the composer's measured
|
// Out of flow so the queue never inflates the composer's measured
|
||||||
// height (that drives thread bottom padding → chat resizes on
|
// height (that drives thread bottom padding → chat resizes on
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
|||||||
return `@${kind}:${formatRefValue(rel)}`
|
return `@${kind}:${formatRefValue(rel)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
|
||||||
|
* dropping any that carry no path. */
|
||||||
|
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
|
||||||
|
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
|
||||||
|
}
|
||||||
|
|
||||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||||
if (!refs.length) {
|
if (!refs.length) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
DIRECTIVE_CHIP_CLASS,
|
DIRECTIVE_CHIP_CLASS,
|
||||||
directiveIconElement,
|
directiveIconElement,
|
||||||
directiveIconSvg,
|
directiveIconSvg,
|
||||||
formatRefValue
|
formatRefValue,
|
||||||
|
slashChipClass,
|
||||||
|
type SlashChipKind,
|
||||||
|
slashIconElement
|
||||||
} from '@/components/assistant-ui/directive-text'
|
} from '@/components/assistant-ui/directive-text'
|
||||||
|
|
||||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||||
@@ -77,6 +80,24 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
|
|||||||
return chip
|
return chip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
|
||||||
|
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
|
||||||
|
* it back to the exact text that gets submitted. */
|
||||||
|
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
|
||||||
|
const chip = document.createElement('span')
|
||||||
|
const text = document.createElement('span')
|
||||||
|
|
||||||
|
chip.contentEditable = 'false'
|
||||||
|
chip.dataset.refText = command
|
||||||
|
chip.dataset.slashKind = kind
|
||||||
|
chip.className = slashChipClass(kind)
|
||||||
|
text.className = 'truncate'
|
||||||
|
text.textContent = label || command
|
||||||
|
chip.append(slashIconElement(kind), text)
|
||||||
|
|
||||||
|
return chip
|
||||||
|
}
|
||||||
|
|
||||||
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
|
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
|
||||||
const lines = text.split('\n')
|
const lines = text.split('\n')
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useI18n } from '@/i18n'
|
|
||||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
|
||||||
import { useTheme } from '@/themes/context'
|
|
||||||
|
|
||||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
|
||||||
|
|
||||||
interface SkinSlashPopoverProps {
|
|
||||||
draft: string
|
|
||||||
onSelect: (command: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const c = t.composer
|
|
||||||
const { availableThemes, themeName } = useTheme()
|
|
||||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-label={c.themeSuggestions}
|
|
||||||
className={COMPLETION_DRAWER_CLASS}
|
|
||||||
data-slot="composer-skin-completion-drawer"
|
|
||||||
data-state="open"
|
|
||||||
role="listbox"
|
|
||||||
>
|
|
||||||
<div className="grid gap-0.5 pt-0.5">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
|
||||||
{c.themeTryPre}
|
|
||||||
<span className="font-mono text-foreground/80">/skin list</span>
|
|
||||||
{c.themeTryPost}
|
|
||||||
</CompletionDrawerEmpty>
|
|
||||||
) : (
|
|
||||||
items.map(item => (
|
|
||||||
<button
|
|
||||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
|
||||||
key={item.text}
|
|
||||||
onClick={() => {
|
|
||||||
triggerHaptic('selection')
|
|
||||||
onSelect(item.text)
|
|
||||||
}}
|
|
||||||
onMouseDown={event => event.preventDefault()}
|
|
||||||
role="option"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
|
|
||||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,33 @@ describe('detectTrigger', () => {
|
|||||||
it('returns null for plain text', () => {
|
it('returns null for plain text', () => {
|
||||||
expect(detectTrigger('hello there')).toBeNull()
|
expect(detectTrigger('hello there')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the slash trigger live while typing args', () => {
|
||||||
|
expect(detectTrigger('/personality ')).toEqual({
|
||||||
|
kind: '/',
|
||||||
|
query: 'personality ',
|
||||||
|
tokenLength: 13
|
||||||
|
})
|
||||||
|
expect(detectTrigger('/personality alic')).toEqual({
|
||||||
|
kind: '/',
|
||||||
|
query: 'personality alic',
|
||||||
|
tokenLength: 17
|
||||||
|
})
|
||||||
|
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||||
|
kind: '/',
|
||||||
|
query: 'tools enable foo',
|
||||||
|
tokenLength: 17
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat file-style paths as slash triggers', () => {
|
||||||
|
expect(detectTrigger('src/foo/bar')).toBeNull()
|
||||||
|
expect(detectTrigger('/path/to/file')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still anchors at-mention triggers strictly at the token edge', () => {
|
||||||
|
expect(detectTrigger('@file:path with space')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('extractClipboardImageBlobs', () => {
|
describe('extractClipboardImageBlobs', () => {
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export interface TriggerState {
|
|||||||
tokenLength: number
|
tokenLength: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||||
|
// single tokens. `/` triggers keep going so the popover stays live while the
|
||||||
|
// user types args (`/personality alic` → arg completer suggests `alice`).
|
||||||
|
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
|
||||||
|
// paths like `src/foo/bar`.
|
||||||
|
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||||
|
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||||
|
|
||||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||||
export function blobDedupeKey(blob: Blob): string {
|
export function blobDedupeKey(blob: Blob): string {
|
||||||
@@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||||
const match = TRIGGER_RE.exec(textBefore)
|
const slash = SLASH_TRIGGER_RE.exec(textBefore)
|
||||||
|
|
||||||
if (!match) {
|
if (slash) {
|
||||||
return null
|
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||||
|
|
||||||
|
if (at) {
|
||||||
|
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,17 @@ describe('ComposerTriggerPopover i18n', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders localized loading copy for slash commands', () => {
|
it('renders localized loading copy for slash commands', () => {
|
||||||
const { container } = renderPopover('/', true)
|
renderPopover('/', true)
|
||||||
|
|
||||||
|
// While loading the popover shows only the spinner + loading copy — the
|
||||||
|
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
|
||||||
expect(screen.getByText('查找中…')).toBeTruthy()
|
expect(screen.getByText('查找中…')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the slash empty-state hint when not loading', () => {
|
||||||
|
const { container } = renderPopover('/')
|
||||||
|
|
||||||
|
expect(screen.getByText('没有匹配项。')).toBeTruthy()
|
||||||
expect(container.textContent).toContain('/help')
|
expect(container.textContent).toContain('/help')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -7,7 +9,6 @@ import { cn } from '@/lib/utils'
|
|||||||
import {
|
import {
|
||||||
COMPLETION_DRAWER_BELOW_CLASS,
|
COMPLETION_DRAWER_BELOW_CLASS,
|
||||||
COMPLETION_DRAWER_CLASS,
|
COMPLETION_DRAWER_CLASS,
|
||||||
COMPLETION_DRAWER_ROW_CLASS,
|
|
||||||
CompletionDrawerEmpty
|
CompletionDrawerEmpty
|
||||||
} from './completion-drawer'
|
} from './completion-drawer'
|
||||||
|
|
||||||
@@ -23,11 +24,7 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
|
|||||||
url: 'globe'
|
url: 'globe'
|
||||||
}
|
}
|
||||||
|
|
||||||
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
function atIcon(item: Unstable_TriggerItem) {
|
||||||
if (kind === '/') {
|
|
||||||
return 'terminal'
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = item.metadata as { rawText?: string } | undefined
|
const meta = item.metadata as { rawText?: string } | undefined
|
||||||
const raw = meta?.rawText || item.label
|
const raw = meta?.rawText || item.label
|
||||||
|
|
||||||
@@ -42,6 +39,18 @@ function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
|||||||
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RowMeta {
|
||||||
|
display?: string
|
||||||
|
group?: string
|
||||||
|
meta?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_BASE_CLASS = [
|
||||||
|
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
|
||||||
|
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
|
||||||
|
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||||
|
].join(' ')
|
||||||
|
|
||||||
interface ComposerTriggerPopoverProps {
|
interface ComposerTriggerPopoverProps {
|
||||||
activeIndex: number
|
activeIndex: number
|
||||||
items: readonly Unstable_TriggerItem[]
|
items: readonly Unstable_TriggerItem[]
|
||||||
@@ -63,6 +72,9 @@ export function ComposerTriggerPopover({
|
|||||||
}: ComposerTriggerPopoverProps) {
|
}: ComposerTriggerPopoverProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.composer
|
const copy = t.composer
|
||||||
|
const isSlash = kind === '/'
|
||||||
|
|
||||||
|
let lastGroup: string | undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -73,41 +85,94 @@ export function ComposerTriggerPopover({
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
|
loading ? (
|
||||||
{kind === '@' ? (
|
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||||
<>
|
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
<span>{copy.lookupLoading}</span>
|
||||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
</div>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
|
||||||
<>
|
{kind === '@' ? (
|
||||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
<>
|
||||||
</>
|
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||||
)}
|
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||||
</CompletionDrawerEmpty>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CompletionDrawerEmpty>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
items.map((item, index) => {
|
items.map((item, index) => {
|
||||||
const meta = item.metadata as { display?: string; meta?: string } | undefined
|
const meta = item.metadata as RowMeta | undefined
|
||||||
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
|
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
|
||||||
const description = meta?.meta || item.description
|
const description = meta?.meta || item.description
|
||||||
|
const group = meta?.group?.trim()
|
||||||
|
const showHeader = isSlash && Boolean(group) && group !== lastGroup
|
||||||
|
const isFirstHeader = lastGroup === undefined
|
||||||
|
lastGroup = group || lastGroup
|
||||||
|
const active = index === activeIndex
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Fragment key={item.id}>
|
||||||
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
|
{showHeader && (
|
||||||
data-highlighted={index === activeIndex ? '' : undefined}
|
<div
|
||||||
key={item.id}
|
className={cn(
|
||||||
onClick={() => onPick(item)}
|
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
|
||||||
onMouseEnter={() => onHover(index)}
|
isFirstHeader ? 'pt-0.5' : 'pt-2'
|
||||||
type="button"
|
)}
|
||||||
>
|
>
|
||||||
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
{group}
|
||||||
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
|
</div>
|
||||||
</span>
|
|
||||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
|
||||||
{description && (
|
|
||||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
|
||||||
|
data-highlighted={active ? '' : undefined}
|
||||||
|
onClick={() => onPick(item)}
|
||||||
|
onMouseEnter={() => onHover(index)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSlash ? (
|
||||||
|
<>
|
||||||
|
{/* Active row (keyboard nav or hover) un-truncates inline so
|
||||||
|
long command names / descriptions stay readable without a
|
||||||
|
floating tooltip. */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[0.8125rem] font-medium leading-snug text-foreground',
|
||||||
|
active ? 'whitespace-normal break-words' : 'truncate'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
{description && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
|
||||||
|
active ? 'whitespace-normal break-words' : 'truncate'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||||
|
<Codicon name={atIcon(item)} size="0.875rem" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
{description && (
|
||||||
|
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Fragment>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
57
apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
|
||||||
|
|
||||||
|
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
|
||||||
|
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
|
||||||
|
// an inline @file: ref (in-app, workspace-relative, gateway-resolvable) or goes
|
||||||
|
// through the upload pipeline (OS drop — absolute local path a remote gateway
|
||||||
|
// can't read, plus image bytes for vision).
|
||||||
|
const osDrop = (path: string): DroppedFile => ({ file: new File(['x'], path.split('/').pop() || 'f'), path })
|
||||||
|
const inAppRef = (path: string, extra: Partial<DroppedFile> = {}): DroppedFile => ({ path, ...extra })
|
||||||
|
|
||||||
|
describe('partitionDroppedFiles', () => {
|
||||||
|
it('routes File-bearing OS drops to osDrops and path-only in-app drags to inAppRefs', () => {
|
||||||
|
const finderPdf = osDrop('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
|
||||||
|
const projectFile = inAppRef('src/index.ts')
|
||||||
|
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles([finderPdf, projectFile])
|
||||||
|
|
||||||
|
expect(osDrops).toEqual([finderPdf])
|
||||||
|
expect(inAppRefs).toEqual([projectFile])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an OS screenshot drop as an upload target (so it gets byte upload + vision)', () => {
|
||||||
|
const screenshot = osDrop('/var/folders/tmp/Screenshot 2026-06-09.png')
|
||||||
|
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles([screenshot])
|
||||||
|
|
||||||
|
expect(osDrops).toEqual([screenshot])
|
||||||
|
expect(inAppRefs).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps gutter line-range drags inline (no File handle)', () => {
|
||||||
|
const lineRef = inAppRef('src/app.ts', { line: 10, lineEnd: 20 })
|
||||||
|
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles([lineRef])
|
||||||
|
|
||||||
|
expect(osDrops).toEqual([])
|
||||||
|
expect(inAppRefs).toEqual([lineRef])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits a mixed drop and preserves order within each group', () => {
|
||||||
|
const a = inAppRef('a.ts')
|
||||||
|
const b = osDrop('/abs/b.pdf')
|
||||||
|
const c = inAppRef('c.ts')
|
||||||
|
const d = osDrop('/abs/d.png')
|
||||||
|
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles([a, b, c, d])
|
||||||
|
|
||||||
|
expect(inAppRefs).toEqual([a, c])
|
||||||
|
expect(osDrops).toEqual([b, d])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty groups for an empty drop', () => {
|
||||||
|
expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
|
|||||||
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImagePath(filePath: string): boolean {
|
export function isImagePath(filePath: string): boolean {
|
||||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +181,35 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split dropped entries by origin. OS/Finder drops carry a native `File`
|
||||||
|
* handle; in-app drags (project tree, gutter line refs) are path-only.
|
||||||
|
*
|
||||||
|
* The distinction is load-bearing: an in-app path is workspace-relative and
|
||||||
|
* resolves on the gateway as-is, so it stays an inline `@file:`/`@line:` ref.
|
||||||
|
* An OS drop is an absolute path on *this* machine — the gateway can't read it
|
||||||
|
* in remote mode, and an image needs its bytes uploaded to get vision either
|
||||||
|
* way. So OS drops must go through the attachment/upload pipeline rather than
|
||||||
|
* leaking a local path into the prompt text.
|
||||||
|
*/
|
||||||
|
export function partitionDroppedFiles(candidates: DroppedFile[]): {
|
||||||
|
osDrops: DroppedFile[]
|
||||||
|
inAppRefs: DroppedFile[]
|
||||||
|
} {
|
||||||
|
const osDrops: DroppedFile[] = []
|
||||||
|
const inAppRefs: DroppedFile[] = []
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate.file) {
|
||||||
|
osDrops.push(candidate)
|
||||||
|
} else {
|
||||||
|
inAppRefs.push(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { osDrops, inAppRefs }
|
||||||
|
}
|
||||||
|
|
||||||
interface ComposerActionsOptions {
|
interface ComposerActionsOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
currentCwd: string
|
currentCwd: string
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
|
|||||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||||
import { ChatBar, ChatBarFallback } from './composer'
|
import { ChatBar, ChatBarFallback } from './composer'
|
||||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||||
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||||
import type { ChatBarState } from './composer/types'
|
import type { ChatBarState } from './composer/types'
|
||||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||||
@@ -126,7 +126,10 @@ function ChatHeader({
|
|||||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||||
<div
|
<div
|
||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
|
style={{
|
||||||
|
maxWidth:
|
||||||
|
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SessionActionsMenu
|
<SessionActionsMenu
|
||||||
align="start"
|
align="start"
|
||||||
@@ -299,19 +302,25 @@ export function ChatView({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Drop files anywhere in the conversation area, not just on the composer
|
// Drop files anywhere in the conversation area, not just on the composer
|
||||||
// input — appending the same inline `@file:` ref chips the composer drop
|
// input. In-app drags (project tree / gutter) carry workspace-relative paths
|
||||||
// produces (vs. attachment cards) so both surfaces behave identically.
|
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
|
||||||
|
// drops carry absolute local paths that don't exist on a remote gateway (and
|
||||||
|
// images need byte upload for vision), so route them through the attachment
|
||||||
|
// pipeline — otherwise the local path leaks into the prompt verbatim.
|
||||||
const onDropFiles = useCallback(
|
const onDropFiles = useCallback(
|
||||||
(candidates: DroppedFile[]) => {
|
(candidates: DroppedFile[]) => {
|
||||||
const refs = candidates
|
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||||
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
|
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
if (refs.length) {
|
if (refs.length) {
|
||||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (osDrops.length) {
|
||||||
|
void onAttachDroppedItems(osDrops)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[currentCwd]
|
[currentCwd, onAttachDroppedItems]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dropping a sidebar session inserts an @session link the agent can resolve
|
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||||
|
|||||||
@@ -446,7 +446,9 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
// Prefer bytes the caller already handed us (a pasted/dropped
|
||||||
|
// screenshot) over re-reading a path that may be transient/unreadable.
|
||||||
|
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
setState({ dataUrl, loading: false })
|
setState({ dataUrl, loading: false })
|
||||||
@@ -484,7 +486,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return <PageLoader label={t.preview.loading} />
|
return <PageLoader label={t.preview.loading} />
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type { CronJob } from '@/types/hermes'
|
|||||||
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
|
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
|
||||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||||
|
|
||||||
|
import { SidebarLoadMoreRow } from './load-more-row'
|
||||||
|
|
||||||
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
||||||
|
|
||||||
// Recent runs shown in the inline quick-peek — enough to glance at history
|
// Recent runs shown in the inline quick-peek — enough to glance at history
|
||||||
@@ -24,6 +26,11 @@ const PEEK_RUN_LIMIT = 5
|
|||||||
// open peek so a freshly-fired run shows up within a few seconds.
|
// open peek so a freshly-fired run shows up within a few seconds.
|
||||||
const PEEK_POLL_INTERVAL_MS = 8000
|
const PEEK_POLL_INTERVAL_MS = 8000
|
||||||
|
|
||||||
|
// Keep the section compact: show a few jobs up front, reveal more in larger
|
||||||
|
// steps on demand (mirrors the messaging sections in the sidebar).
|
||||||
|
const INITIAL_VISIBLE_JOBS = 3
|
||||||
|
const LOAD_MORE_STEP = 10
|
||||||
|
|
||||||
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
||||||
|
|
||||||
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
||||||
@@ -33,17 +40,25 @@ function relativeTime(targetMs: number, nowMs: number): string {
|
|||||||
const abs = Math.abs(diff)
|
const abs = Math.abs(diff)
|
||||||
const sign = diff < 0 ? -1 : 1
|
const sign = diff < 0 ? -1 : 1
|
||||||
|
|
||||||
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
|
if (abs < 60_000) {
|
||||||
|
return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
|
||||||
|
}
|
||||||
|
|
||||||
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
|
if (abs < 3_600_000) {
|
||||||
|
return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
|
||||||
|
}
|
||||||
|
|
||||||
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
|
if (abs < 86_400_000) {
|
||||||
|
return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
|
||||||
|
}
|
||||||
|
|
||||||
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
|
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextRunMs(job: CronJob): null | number {
|
function nextRunMs(job: CronJob): null | number {
|
||||||
if (!job.next_run_at) {return null}
|
if (!job.next_run_at) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const ms = Date.parse(job.next_run_at)
|
const ms = Date.parse(job.next_run_at)
|
||||||
|
|
||||||
@@ -54,7 +69,9 @@ function nextRunMs(job: CronJob): null | number {
|
|||||||
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
|
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
|
||||||
// narrow sidebar.
|
// narrow sidebar.
|
||||||
function formatRunTime(seconds?: null | number): string {
|
function formatRunTime(seconds?: null | number): string {
|
||||||
if (!seconds) {return '—'}
|
if (!seconds) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
const date = new Date(seconds * 1000)
|
const date = new Date(seconds * 1000)
|
||||||
|
|
||||||
@@ -90,11 +107,15 @@ export function SidebarCronJobsSection({
|
|||||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||||
// Single-open inline peek so the section stays scannable.
|
// Single-open inline peek so the section stays scannable.
|
||||||
const [peekJobId, setPeekJobId] = useState<null | string>(null)
|
const [peekJobId, setPeekJobId] = useState<null | string>(null)
|
||||||
|
// Rows revealed so far; starts compact, grows in steps via "load more".
|
||||||
|
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
|
||||||
|
|
||||||
// One clock for the whole section (rows are pure) so the countdowns tick
|
// One clock for the whole section (rows are pure) so the countdowns tick
|
||||||
// without re-rendering the rest of the sidebar. Only runs while expanded.
|
// without re-rendering the rest of the sidebar. Only runs while expanded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {return}
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||||
|
|
||||||
@@ -108,17 +129,25 @@ export function SidebarCronJobsSection({
|
|||||||
const an = nextRunMs(a)
|
const an = nextRunMs(a)
|
||||||
const bn = nextRunMs(b)
|
const bn = nextRunMs(b)
|
||||||
|
|
||||||
if (an !== null && bn !== null && an !== bn) {return an - bn}
|
if (an !== null && bn !== null && an !== bn) {
|
||||||
|
return an - bn
|
||||||
|
}
|
||||||
|
|
||||||
if (an === null && bn !== null) {return 1}
|
if (an === null && bn !== null) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if (an !== null && bn === null) {return -1}
|
if (an !== null && bn === null) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
return jobTitle(a).localeCompare(jobTitle(b))
|
return jobTitle(a).localeCompare(jobTitle(b))
|
||||||
})
|
})
|
||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
const shown = sorted.slice(0, max)
|
const cap = Math.min(visibleCount, max)
|
||||||
|
const shown = sorted.slice(0, cap)
|
||||||
|
const hiddenCount = Math.min(sorted.length, max) - shown.length
|
||||||
// When capped, signal "50+" rather than implying the list is complete.
|
// When capped, signal "50+" rather than implying the list is complete.
|
||||||
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
|
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
|
||||||
|
|
||||||
@@ -139,7 +168,7 @@ export function SidebarCronJobsSection({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
|
||||||
{shown.map(job => (
|
{shown.map(job => (
|
||||||
<CronJobSidebarRow
|
<CronJobSidebarRow
|
||||||
expanded={peekJobId === job.id}
|
expanded={peekJobId === job.id}
|
||||||
@@ -152,6 +181,12 @@ export function SidebarCronJobsSection({
|
|||||||
onTrigger={() => onTriggerJob(job.id)}
|
onTrigger={() => onTriggerJob(job.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<SidebarLoadMoreRow
|
||||||
|
onClick={() => setVisibleCount(count => count + LOAD_MORE_STEP)}
|
||||||
|
step={Math.min(LOAD_MORE_STEP, hiddenCount)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
)}
|
)}
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -181,11 +216,7 @@ function CronJobSidebarRow({
|
|||||||
const next = nextRunMs(job)
|
const next = nextRunMs(job)
|
||||||
const label = jobTitle(job)
|
const label = jobTitle(job)
|
||||||
|
|
||||||
const meta = INACTIVE_STATES.has(state)
|
const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
|
||||||
? (c.states[state] ?? state)
|
|
||||||
: next !== null
|
|
||||||
? relativeTime(next, nowMs)
|
|
||||||
: '—'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -257,13 +288,7 @@ function CronJobSidebarRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CronJobSidebarRuns({
|
function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) {
|
||||||
jobId,
|
|
||||||
onOpenRun
|
|
||||||
}: {
|
|
||||||
jobId: string
|
|
||||||
onOpenRun: (sessionId: string) => void
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const c = t.cron
|
const c = t.cron
|
||||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||||
@@ -275,16 +300,22 @@ function CronJobSidebarRuns({
|
|||||||
const load = () =>
|
const load = () =>
|
||||||
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
|
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!cancelled) {setRuns(result)}
|
if (!cancelled) {
|
||||||
|
setRuns(result)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
if (!cancelled) {
|
||||||
|
setRuns(prev => prev ?? [])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
void load()
|
void load()
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
if (document.visibilityState === 'visible') {void load()}
|
if (document.visibilityState === 'visible') {
|
||||||
|
void load()
|
||||||
|
}
|
||||||
}, PEEK_POLL_INTERVAL_MS)
|
}, PEEK_POLL_INTERVAL_MS)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
$pinnedSessionIds,
|
$pinnedSessionIds,
|
||||||
$sidebarAgentsGrouped,
|
$sidebarAgentsGrouped,
|
||||||
$sidebarCronOpen,
|
$sidebarCronOpen,
|
||||||
|
$sidebarMessagingOpenIds,
|
||||||
$sidebarOpen,
|
$sidebarOpen,
|
||||||
$sidebarOverlayMounted,
|
$sidebarOverlayMounted,
|
||||||
$sidebarPinsOpen,
|
$sidebarPinsOpen,
|
||||||
@@ -64,6 +65,7 @@ import {
|
|||||||
setSidebarSessionOrderIds,
|
setSidebarSessionOrderIds,
|
||||||
setSidebarWorkspaceOrderIds,
|
setSidebarWorkspaceOrderIds,
|
||||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||||
|
toggleSidebarMessagingOpen,
|
||||||
unpinSession
|
unpinSession
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +78,9 @@ import {
|
|||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import {
|
import {
|
||||||
$cronSessions,
|
$cronSessions,
|
||||||
|
$messagingPlatformTotals,
|
||||||
|
$messagingSessions,
|
||||||
|
$messagingTruncated,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessionProfileTotals,
|
$sessionProfileTotals,
|
||||||
$sessions,
|
$sessions,
|
||||||
@@ -90,12 +95,19 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
|||||||
import type { SidebarNavItem } from '../../types'
|
import type { SidebarNavItem } from '../../types'
|
||||||
|
|
||||||
import { SidebarCronJobsSection } from './cron-jobs-section'
|
import { SidebarCronJobsSection } from './cron-jobs-section'
|
||||||
|
import { SidebarLoadMoreRow } from './load-more-row'
|
||||||
import { ProfileRail } from './profile-switcher'
|
import { ProfileRail } from './profile-switcher'
|
||||||
import { SidebarSessionRow } from './session-row'
|
import { SidebarSessionRow } from './session-row'
|
||||||
import { VirtualSessionList } from './virtual-session-list'
|
import { VirtualSessionList } from './virtual-session-list'
|
||||||
|
|
||||||
const VIRTUALIZE_THRESHOLD = 25
|
const VIRTUALIZE_THRESHOLD = 25
|
||||||
|
|
||||||
|
// Non-session groups (messaging platforms) stay compact: show a few rows up
|
||||||
|
// front, reveal more in larger steps on demand. Keeps a busy platform from
|
||||||
|
// dominating the sidebar before the user asks to see it.
|
||||||
|
const NON_SESSION_INITIAL_ROWS = 3
|
||||||
|
const NON_SESSION_LOAD_STEP = 10
|
||||||
|
|
||||||
// Render the modifier key the user actually presses on this platform. The
|
// Render the modifier key the user actually presses on this platform. The
|
||||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||||
@@ -124,7 +136,16 @@ const WORKSPACE_PAGE = 5
|
|||||||
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
|
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
|
||||||
const PROFILE_INITIAL_PAGE = 5
|
const PROFILE_INITIAL_PAGE = 5
|
||||||
const GROUP_DND_ID_PREFIX = 'group:'
|
const GROUP_DND_ID_PREFIX = 'group:'
|
||||||
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
|
|
||||||
|
// Two modes via the `compact` height variant (styles.css):
|
||||||
|
// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1.
|
||||||
|
// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one.
|
||||||
|
// Sections stay shrink-0 so none can be squeezed below its content and bleed onto
|
||||||
|
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
|
||||||
|
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
|
||||||
|
|
||||||
|
// A non-session group's scroll body: own scroller when tall, flattened when compact.
|
||||||
|
const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
|
||||||
|
|
||||||
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
|
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
|
||||||
|
|
||||||
@@ -141,24 +162,25 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
|
|||||||
|
|
||||||
const byId = new Map(items.map(item => [getId(item), item]))
|
const byId = new Map(items.map(item => [getId(item), item]))
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const out: T[] = []
|
const ordered: T[] = []
|
||||||
|
|
||||||
for (const id of orderIds) {
|
for (const id of orderIds) {
|
||||||
const item = byId.get(id)
|
const item = byId.get(id)
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
out.push(item)
|
ordered.push(item)
|
||||||
seen.add(id)
|
seen.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
// Items missing from the persisted order are new since it was last
|
||||||
if (!seen.has(getId(item))) {
|
// reconciled. Callers pass recency-sorted lists (newest first), so surface
|
||||||
out.push(item)
|
// these at the TOP instead of burying them beneath the saved order —
|
||||||
}
|
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
|
||||||
}
|
// as "my latest session never showed up".
|
||||||
|
const fresh = items.filter(item => !seen.has(getId(item)))
|
||||||
|
|
||||||
return out
|
return fresh.length ? [...fresh, ...ordered] : ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
||||||
@@ -171,17 +193,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = new Set(currentIds)
|
const current = new Set(currentIds)
|
||||||
const next = orderIds.filter(id => current.has(id))
|
const retained = orderIds.filter(id => current.has(id))
|
||||||
const known = new Set(next)
|
const retainedSet = new Set(retained)
|
||||||
|
|
||||||
for (const id of currentIds) {
|
// New ids (absent from the saved order) are the newest sessions/groups; keep
|
||||||
if (!known.has(id)) {
|
// them ahead of the persisted order so fresh activity surfaces at the top of
|
||||||
next.push(id)
|
// the sidebar rather than being appended to the bottom.
|
||||||
known.add(id)
|
const fresh = currentIds.filter(id => !retainedSet.has(id))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
return [...fresh, ...retained]
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameIds(left: string[], right: string[]) {
|
function sameIds(left: string[], right: string[]) {
|
||||||
@@ -251,43 +271,6 @@ function workspaceGroupsFor(
|
|||||||
return [...groups.values()]
|
return [...groups.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
|
|
||||||
localSessions: SessionInfo[]
|
|
||||||
sourceGroups: SidebarSessionGroup[]
|
|
||||||
} {
|
|
||||||
const groups = new Map<string, SidebarSessionGroup>()
|
|
||||||
const localSessions: SessionInfo[] = []
|
|
||||||
|
|
||||||
for (const session of sessions) {
|
|
||||||
const sourceId = normalizeSessionSource(session.source)
|
|
||||||
|
|
||||||
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
|
|
||||||
localSessions.push(session)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = sessionSourceLabel(sourceId) ?? sourceId
|
|
||||||
|
|
||||||
const group = groups.get(sourceId) ?? {
|
|
||||||
id: `source:${sourceId}`,
|
|
||||||
label,
|
|
||||||
mode: 'source',
|
|
||||||
path: null,
|
|
||||||
sessions: [],
|
|
||||||
sourceId
|
|
||||||
}
|
|
||||||
|
|
||||||
group.sessions.push(session)
|
|
||||||
groups.set(sourceId, group)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
localSessions,
|
|
||||||
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useSortableBindings(id: string) {
|
function useSortableBindings(id: string) {
|
||||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||||
|
|
||||||
@@ -309,6 +292,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||||||
onNavigate: (item: SidebarNavItem) => void
|
onNavigate: (item: SidebarNavItem) => void
|
||||||
onLoadMoreSessions: () => void
|
onLoadMoreSessions: () => void
|
||||||
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
|
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
|
||||||
|
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
|
||||||
onResumeSession: (sessionId: string) => void
|
onResumeSession: (sessionId: string) => void
|
||||||
onDeleteSession: (sessionId: string) => void
|
onDeleteSession: (sessionId: string) => void
|
||||||
onArchiveSession: (sessionId: string) => void
|
onArchiveSession: (sessionId: string) => void
|
||||||
@@ -322,6 +306,7 @@ export function ChatSidebar({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onLoadMoreSessions,
|
onLoadMoreSessions,
|
||||||
onLoadMoreProfileSessions,
|
onLoadMoreProfileSessions,
|
||||||
|
onLoadMoreMessaging,
|
||||||
onResumeSession,
|
onResumeSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onArchiveSession,
|
onArchiveSession,
|
||||||
@@ -345,6 +330,9 @@ export function ChatSidebar({
|
|||||||
const sessions = useStore($sessions)
|
const sessions = useStore($sessions)
|
||||||
const cronSessions = useStore($cronSessions)
|
const cronSessions = useStore($cronSessions)
|
||||||
const cronJobs = useStore($cronJobs)
|
const cronJobs = useStore($cronJobs)
|
||||||
|
const messagingSessions = useStore($messagingSessions)
|
||||||
|
const messagingPlatformTotals = useStore($messagingPlatformTotals)
|
||||||
|
const messagingTruncated = useStore($messagingTruncated)
|
||||||
const sessionsLoading = useStore($sessionsLoading)
|
const sessionsLoading = useStore($sessionsLoading)
|
||||||
const sessionsTotal = useStore($sessionsTotal)
|
const sessionsTotal = useStore($sessionsTotal)
|
||||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||||
@@ -364,6 +352,10 @@ export function ChatSidebar({
|
|||||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||||
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
|
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
|
||||||
|
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
|
||||||
|
const messagingOpenIds = useStore($sidebarMessagingOpenIds)
|
||||||
|
// Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS).
|
||||||
|
const [messagingVisible, setMessagingVisible] = useState<Record<string, number>>({})
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const trimmedQuery = searchQuery.trim()
|
const trimmedQuery = searchQuery.trim()
|
||||||
|
|
||||||
@@ -529,24 +521,12 @@ export function ChatSidebar({
|
|||||||
[unpinnedAgentSessions, agentOrderIds]
|
[unpinnedAgentSessions, agentOrderIds]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
|
// Recents are local-only: messaging-platform sessions are fetched as their
|
||||||
() => sourceSessionGroupsFor(agentSessions),
|
// own slice ($messagingSessions) and rendered in self-managed per-platform
|
||||||
[agentSessions]
|
// sections below, so there is no source-grouping magic to untangle here.
|
||||||
)
|
|
||||||
|
|
||||||
const orderedSourceGroups = useMemo(
|
|
||||||
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
|
|
||||||
[sourceGroups, workspaceOrderIds]
|
|
||||||
)
|
|
||||||
|
|
||||||
const agentGroups = useMemo(
|
const agentGroups = useMemo(
|
||||||
() =>
|
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
|
||||||
orderByIds(
|
[agentSessions, s.noWorkspace, workspaceOrderIds]
|
||||||
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
|
|
||||||
g => g.id,
|
|
||||||
workspaceOrderIds
|
|
||||||
),
|
|
||||||
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const loadMoreForProfileGroup = useCallback(
|
const loadMoreForProfileGroup = useCallback(
|
||||||
@@ -564,6 +544,76 @@ export function ChatSidebar({
|
|||||||
[onLoadMoreProfileSessions]
|
[onLoadMoreProfileSessions]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const loadMoreForMessaging = useCallback(
|
||||||
|
(platform: string) => {
|
||||||
|
if (!onLoadMoreMessaging) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
|
||||||
|
|
||||||
|
void Promise.resolve(onLoadMoreMessaging(platform))
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
|
||||||
|
},
|
||||||
|
[onLoadMoreMessaging]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reveal another batch of a platform's rows; fetch from the backend too if we
|
||||||
|
// run past what's loaded and more remain on disk.
|
||||||
|
const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => {
|
||||||
|
const next = (messagingVisible[platform] ?? NON_SESSION_INITIAL_ROWS) + NON_SESSION_LOAD_STEP
|
||||||
|
|
||||||
|
setMessagingVisible(prev => ({ ...prev, [platform]: next }))
|
||||||
|
|
||||||
|
if (next > loaded && hasMore) {
|
||||||
|
loadMoreForMessaging(platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each messaging platform is its own self-managed section: split the
|
||||||
|
// separately-fetched messaging slice by source, newest platform first, rows
|
||||||
|
// within a platform by recency. Per-platform totals (when a "load more" has
|
||||||
|
// resolved them) drive the count + whether more remain on disk.
|
||||||
|
const messagingGroups = useMemo<MessagingSection[]>(() => {
|
||||||
|
if (!messagingSessions.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySource = new Map<string, SessionInfo[]>()
|
||||||
|
|
||||||
|
for (const session of messagingSessions) {
|
||||||
|
const sourceId = normalizeSessionSource(session.source)
|
||||||
|
|
||||||
|
if (!sourceId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = bySource.get(sourceId) ?? []
|
||||||
|
list.push(session)
|
||||||
|
bySource.set(sourceId, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...bySource.entries()]
|
||||||
|
.map(([sourceId, list]) => {
|
||||||
|
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
|
||||||
|
const known = messagingPlatformTotals[sourceId]
|
||||||
|
const total = Math.max(ordered.length, known ?? 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Known exact total → more exist iff total exceeds loaded; otherwise
|
||||||
|
// the seed fetch was capped, so assume more until a per-platform load
|
||||||
|
// resolves the count.
|
||||||
|
hasMore: known != null ? known > ordered.length : messagingTruncated,
|
||||||
|
label: sessionSourceLabel(sourceId) ?? sourceId,
|
||||||
|
sessions: ordered,
|
||||||
|
sourceId,
|
||||||
|
total
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
|
||||||
|
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
|
||||||
|
|
||||||
// ALL-profiles view: one collapsible group per profile, color on the header
|
// ALL-profiles view: one collapsible group per profile, color on the header
|
||||||
// (not on every row). Default profile floats to the top, the rest alpha.
|
// (not on every row). Default profile floats to the top, the rest alpha.
|
||||||
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
|
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
|
||||||
@@ -610,56 +660,7 @@ export function ChatSidebar({
|
|||||||
sessionProfileTotals
|
sessionProfileTotals
|
||||||
])
|
])
|
||||||
|
|
||||||
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
|
const displayAgentSessions = agentSessions
|
||||||
|
|
||||||
const displayAgentGroups = useMemo(() => {
|
|
||||||
if (orderedSourceGroups.length) {
|
|
||||||
const localGroups = agentsGrouped
|
|
||||||
? agentGroups
|
|
||||||
: localAgentSessions.length
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: 'local-sessions',
|
|
||||||
label: 'Local',
|
|
||||||
mode: 'workspace' as const,
|
|
||||||
path: null,
|
|
||||||
sessions: localAgentSessions
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
|
|
||||||
}, [
|
|
||||||
agentGroups,
|
|
||||||
agentsGrouped,
|
|
||||||
localAgentSessions,
|
|
||||||
orderedSourceGroups,
|
|
||||||
profileGroups,
|
|
||||||
showAllProfiles,
|
|
||||||
workspaceOrderIds
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!displayAgentGroups?.length || showAllProfiles) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = reconcileOrderIds(
|
|
||||||
displayAgentGroups.map(g => g.id),
|
|
||||||
workspaceOrderIds
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!sameIds(next, workspaceOrderIds)) {
|
|
||||||
setSidebarWorkspaceOrderIds(next)
|
|
||||||
}
|
|
||||||
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
|
|
||||||
|
|
||||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
|
||||||
|
|
||||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
|
||||||
|
|
||||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||||
// unified set. When scoped to one profile it must compare that profile's own
|
// unified set. When scoped to one profile it must compare that profile's own
|
||||||
@@ -680,6 +681,33 @@ export function ChatSidebar({
|
|||||||
|
|
||||||
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
|
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
|
||||||
|
|
||||||
|
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
|
||||||
|
|
||||||
|
// The recents list owns its own (virtualized) scroll container only when it's a
|
||||||
|
// long flat list. In that case it must keep its scroller even in short mode, so
|
||||||
|
// we don't flatten it (flattening would defeat virtualization). Short flat lists
|
||||||
|
// and grouped views flatten into the single outer scroll instead.
|
||||||
|
const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!displayAgentGroups?.length || showAllProfiles) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = reconcileOrderIds(
|
||||||
|
displayAgentGroups.map(g => g.id),
|
||||||
|
workspaceOrderIds
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!sameIds(next, workspaceOrderIds)) {
|
||||||
|
setSidebarWorkspaceOrderIds(next)
|
||||||
|
}
|
||||||
|
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
|
||||||
|
|
||||||
|
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||||
|
|
||||||
|
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||||
|
|
||||||
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return
|
return
|
||||||
@@ -792,9 +820,7 @@ export function ChatSidebar({
|
|||||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||||
{contentVisible && (
|
{contentVisible && (
|
||||||
<>
|
<>
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||||
{s.nav[item.id] ?? item.label}
|
|
||||||
</span>
|
|
||||||
{isNewSession && (
|
{isNewSession && (
|
||||||
<KbdGroup
|
<KbdGroup
|
||||||
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
||||||
@@ -823,135 +849,191 @@ export function ChatSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contentVisible && showSessionSections && trimmedQuery && (
|
{contentVisible && showSessionSections && (
|
||||||
<SidebarSessionsSection
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
|
||||||
activeSessionId={activeSidebarSessionId}
|
{trimmedQuery && (
|
||||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
<SidebarSessionsSection
|
||||||
emptyState={
|
activeSessionId={activeSidebarSessionId}
|
||||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||||
{s.noMatch(trimmedQuery)}
|
emptyState={
|
||||||
</div>
|
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||||
}
|
{s.noMatch(trimmedQuery)}
|
||||||
label={s.results}
|
</div>
|
||||||
labelMeta={String(searchResults.length)}
|
}
|
||||||
onArchiveSession={onArchiveSession}
|
label={s.results}
|
||||||
onDeleteSession={onDeleteSession}
|
labelMeta={String(searchResults.length)}
|
||||||
onResumeSession={onResumeSession}
|
onArchiveSession={onArchiveSession}
|
||||||
onToggle={() => undefined}
|
onDeleteSession={onDeleteSession}
|
||||||
onTogglePin={pinSession}
|
onResumeSession={onResumeSession}
|
||||||
open
|
onToggle={() => undefined}
|
||||||
pinned={false}
|
onTogglePin={pinSession}
|
||||||
rootClassName="min-h-0 flex-1 p-0"
|
open
|
||||||
sessions={searchResults}
|
pinned={false}
|
||||||
workingSessionIdSet={workingSessionIdSet}
|
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
|
||||||
/>
|
sessions={searchResults}
|
||||||
)}
|
workingSessionIdSet={workingSessionIdSet}
|
||||||
|
/>
|
||||||
{contentVisible && showSessionSections && !trimmedQuery && (
|
|
||||||
<SidebarSessionsSection
|
|
||||||
activeSessionId={activeSidebarSessionId}
|
|
||||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
|
||||||
dndSensors={dndSensors}
|
|
||||||
emptyState={<SidebarPinnedEmptyState />}
|
|
||||||
label={s.pinned}
|
|
||||||
onArchiveSession={onArchiveSession}
|
|
||||||
onDeleteSession={onDeleteSession}
|
|
||||||
onReorder={handlePinnedDragEnd}
|
|
||||||
onResumeSession={onResumeSession}
|
|
||||||
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
|
|
||||||
onTogglePin={unpinSession}
|
|
||||||
open={pinsOpen}
|
|
||||||
pinned
|
|
||||||
rootClassName="shrink-0 p-0 pb-1"
|
|
||||||
sessions={pinnedSessions}
|
|
||||||
sortable={pinnedSessions.length > 1}
|
|
||||||
workingSessionIdSet={workingSessionIdSet}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contentVisible && showSessionSections && !trimmedQuery && (
|
|
||||||
<SidebarSessionsSection
|
|
||||||
activeSessionId={activeSidebarSessionId}
|
|
||||||
contentClassName={cn(
|
|
||||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
|
||||||
// Separate profile sections clearly in the ALL view; rows inside
|
|
||||||
// each group keep their own tight gap-px rhythm.
|
|
||||||
showAllProfiles ? 'gap-3' : 'gap-px'
|
|
||||||
)}
|
)}
|
||||||
dndSensors={dndSensors}
|
|
||||||
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
|
||||||
footer={
|
|
||||||
// Hide "load more" only when workspace-grouped (those groups page
|
|
||||||
// themselves). ALL-profiles now pages per-profile from each profile
|
|
||||||
// header; the global footer only applies to non-ALL views.
|
|
||||||
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
|
||||||
<SidebarLoadMoreRow
|
|
||||||
loading={sessionsLoading}
|
|
||||||
onClick={onLoadMoreSessions}
|
|
||||||
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
forceEmptyState={showSessionSkeletons}
|
|
||||||
groups={displayAgentGroups}
|
|
||||||
headerAction={
|
|
||||||
// Always reserve the icon-xs (size-6) slot so the header keeps the
|
|
||||||
// same height whether or not the toggle renders — otherwise the
|
|
||||||
// "Sessions" label jumps when switching to the ALL-profiles view.
|
|
||||||
// Grouping operates on unpinned recents; if everything is pinned
|
|
||||||
// the toggle does nothing, and it's irrelevant in the ALL-profiles
|
|
||||||
// view (always grouped by profile), so hide the button (not the slot).
|
|
||||||
<div className="grid size-6 shrink-0 place-items-center">
|
|
||||||
{!showAllProfiles && localAgentSessions.length > 0 ? (
|
|
||||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
|
||||||
<Button
|
|
||||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
|
||||||
className={cn(
|
|
||||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
|
||||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
|
||||||
)}
|
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setSidebarRecentsOpen(true)
|
|
||||||
setSidebarAgentsGrouped(!agentsGrouped)
|
|
||||||
}}
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
|
||||||
</Button>
|
|
||||||
</Tip>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
label={s.sessions}
|
|
||||||
labelMeta={recentsMeta}
|
|
||||||
onArchiveSession={onArchiveSession}
|
|
||||||
onDeleteSession={onDeleteSession}
|
|
||||||
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
|
|
||||||
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
|
|
||||||
onResumeSession={onResumeSession}
|
|
||||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
|
||||||
onTogglePin={pinSession}
|
|
||||||
open={agentsOpen}
|
|
||||||
pinned={false}
|
|
||||||
rootClassName="min-h-0 flex-1 p-0"
|
|
||||||
sessions={displayAgentSessions}
|
|
||||||
sortable={!showAllProfiles && agentSessions.length > 1}
|
|
||||||
workingSessionIdSet={workingSessionIdSet}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
|
{!trimmedQuery && (
|
||||||
<SidebarCronJobsSection
|
<SidebarSessionsSection
|
||||||
jobs={cronJobs}
|
activeSessionId={activeSidebarSessionId}
|
||||||
label={s.cronJobs}
|
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
|
||||||
onManageJob={onManageCronJob}
|
dndSensors={dndSensors}
|
||||||
onOpenRun={onResumeSession}
|
emptyState={<SidebarPinnedEmptyState />}
|
||||||
onToggle={() => setSidebarCronOpen(!cronOpen)}
|
label={s.pinned}
|
||||||
onTriggerJob={onTriggerCronJob}
|
onArchiveSession={onArchiveSession}
|
||||||
open={cronOpen}
|
onDeleteSession={onDeleteSession}
|
||||||
/>
|
onReorder={handlePinnedDragEnd}
|
||||||
|
onResumeSession={onResumeSession}
|
||||||
|
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
|
||||||
|
onTogglePin={unpinSession}
|
||||||
|
open={pinsOpen}
|
||||||
|
pinned
|
||||||
|
rootClassName="shrink-0 p-0 pb-1"
|
||||||
|
sessions={pinnedSessions}
|
||||||
|
sortable={pinnedSessions.length > 1}
|
||||||
|
workingSessionIdSet={workingSessionIdSet}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!trimmedQuery && (
|
||||||
|
<SidebarSessionsSection
|
||||||
|
activeSessionId={activeSidebarSessionId}
|
||||||
|
contentClassName={cn(
|
||||||
|
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
||||||
|
// Separate profile sections clearly in the ALL view; rows inside
|
||||||
|
// each group keep their own tight gap-px rhythm.
|
||||||
|
showAllProfiles ? 'gap-3' : 'gap-px',
|
||||||
|
// Flatten into the single scroll when compact — unless this is the
|
||||||
|
// virtualized long list, which must keep its own scroller.
|
||||||
|
!recentsVirtualizes && COMPACT_FLAT
|
||||||
|
)}
|
||||||
|
dndSensors={dndSensors}
|
||||||
|
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
||||||
|
footer={
|
||||||
|
// Hide "load more" only when workspace-grouped (those groups page
|
||||||
|
// themselves). ALL-profiles now pages per-profile from each profile
|
||||||
|
// header; the global footer only applies to non-ALL views.
|
||||||
|
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||||
|
<SidebarLoadMoreRow
|
||||||
|
loading={sessionsLoading}
|
||||||
|
onClick={onLoadMoreSessions}
|
||||||
|
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
forceEmptyState={showSessionSkeletons}
|
||||||
|
groups={displayAgentGroups}
|
||||||
|
headerAction={
|
||||||
|
// Always reserve the icon-xs (size-6) slot so the header keeps the
|
||||||
|
// same height whether or not the toggle renders — otherwise the
|
||||||
|
// "Sessions" label jumps when switching to the ALL-profiles view.
|
||||||
|
// Grouping operates on unpinned recents; if everything is pinned
|
||||||
|
// the toggle does nothing, and it's irrelevant in the ALL-profiles
|
||||||
|
// view (always grouped by profile), so hide the button (not the slot).
|
||||||
|
<div className="grid size-6 shrink-0 place-items-center">
|
||||||
|
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||||
|
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||||
|
<Button
|
||||||
|
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||||
|
className={cn(
|
||||||
|
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||||
|
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||||
|
)}
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setSidebarRecentsOpen(true)
|
||||||
|
setSidebarAgentsGrouped(!agentsGrouped)
|
||||||
|
}}
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||||
|
</Button>
|
||||||
|
</Tip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
label={s.sessions}
|
||||||
|
labelMeta={recentsMeta}
|
||||||
|
onArchiveSession={onArchiveSession}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
|
||||||
|
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
|
||||||
|
onResumeSession={onResumeSession}
|
||||||
|
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||||
|
onTogglePin={pinSession}
|
||||||
|
open={agentsOpen}
|
||||||
|
pinned={false}
|
||||||
|
rootClassName={cn(
|
||||||
|
'min-h-32 flex-1 overflow-hidden p-0',
|
||||||
|
!recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible'
|
||||||
|
)}
|
||||||
|
sessions={displayAgentSessions}
|
||||||
|
sortable={!showAllProfiles && agentSessions.length > 1}
|
||||||
|
workingSessionIdSet={workingSessionIdSet}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!trimmedQuery &&
|
||||||
|
messagingGroups.map(group => {
|
||||||
|
const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS
|
||||||
|
const shownSessions = group.sessions.slice(0, visible)
|
||||||
|
// More to show if rows are hidden behind the cap, or the backend
|
||||||
|
// still has older threads on disk.
|
||||||
|
const canRevealMore = visible < group.sessions.length || group.hasMore
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarSessionsSection
|
||||||
|
activeSessionId={activeSidebarSessionId}
|
||||||
|
contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)}
|
||||||
|
emptyState={null}
|
||||||
|
footer={
|
||||||
|
canRevealMore ? (
|
||||||
|
<SidebarLoadMoreRow
|
||||||
|
loading={Boolean(messagingLoadMorePending[group.sourceId])}
|
||||||
|
onClick={() => revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)}
|
||||||
|
step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.length))}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
key={group.sourceId}
|
||||||
|
label={group.label}
|
||||||
|
labelIcon={
|
||||||
|
<PlatformAvatar
|
||||||
|
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
|
||||||
|
platformId={group.sourceId}
|
||||||
|
platformName={group.label}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
labelMeta={countLabel(group.sessions.length, group.total)}
|
||||||
|
onArchiveSession={onArchiveSession}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
onResumeSession={onResumeSession}
|
||||||
|
onToggle={() => toggleSidebarMessagingOpen(group.sourceId)}
|
||||||
|
onTogglePin={pinSession}
|
||||||
|
open={messagingOpenIds.includes(group.sourceId)}
|
||||||
|
pinned={false}
|
||||||
|
rootClassName="shrink-0 p-0"
|
||||||
|
sessions={shownSessions}
|
||||||
|
workingSessionIdSet={workingSessionIdSet}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!trimmedQuery && cronJobs.length > 0 && (
|
||||||
|
<SidebarCronJobsSection
|
||||||
|
jobs={cronJobs}
|
||||||
|
label={s.cronJobs}
|
||||||
|
onManageJob={onManageCronJob}
|
||||||
|
onOpenRun={onResumeSession}
|
||||||
|
onToggle={() => setSidebarCronOpen(!cronOpen)}
|
||||||
|
onTriggerJob={onTriggerCronJob}
|
||||||
|
open={cronOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
|
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||||
@@ -972,9 +1054,10 @@ interface SidebarSectionHeaderProps {
|
|||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
meta?: React.ReactNode
|
meta?: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
|
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -982,6 +1065,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
{icon}
|
||||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||||
{meta && <SidebarCount>{meta}</SidebarCount>}
|
{meta && <SidebarCount>{meta}</SidebarCount>}
|
||||||
<DisclosureCaret
|
<DisclosureCaret
|
||||||
@@ -1044,6 +1128,14 @@ interface SidebarSessionGroup {
|
|||||||
totalCount?: number
|
totalCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessagingSection {
|
||||||
|
sourceId: string
|
||||||
|
label: string
|
||||||
|
sessions: SessionInfo[]
|
||||||
|
total: number
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarSessionsSectionProps {
|
interface SidebarSessionsSectionProps {
|
||||||
label: string
|
label: string
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -1065,6 +1157,7 @@ interface SidebarSessionsSectionProps {
|
|||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
groups?: SidebarSessionGroup[]
|
groups?: SidebarSessionGroup[]
|
||||||
labelMeta?: React.ReactNode
|
labelMeta?: React.ReactNode
|
||||||
|
labelIcon?: React.ReactNode
|
||||||
sortable?: boolean
|
sortable?: boolean
|
||||||
onReorder?: (event: DragEndEvent) => void
|
onReorder?: (event: DragEndEvent) => void
|
||||||
dndSensors?: ReturnType<typeof useSensors>
|
dndSensors?: ReturnType<typeof useSensors>
|
||||||
@@ -1091,6 +1184,7 @@ function SidebarSessionsSection({
|
|||||||
footer,
|
footer,
|
||||||
groups,
|
groups,
|
||||||
labelMeta,
|
labelMeta,
|
||||||
|
labelIcon,
|
||||||
sortable = false,
|
sortable = false,
|
||||||
onReorder,
|
onReorder,
|
||||||
dndSensors
|
dndSensors
|
||||||
@@ -1181,6 +1275,7 @@ function SidebarSessionsSection({
|
|||||||
inner = (
|
inner = (
|
||||||
<VirtualSessionList
|
<VirtualSessionList
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
|
className={contentClassName}
|
||||||
onArchiveSession={onArchiveSession}
|
onArchiveSession={onArchiveSession}
|
||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
onResumeSession={onResumeSession}
|
onResumeSession={onResumeSession}
|
||||||
@@ -1209,7 +1304,14 @@ function SidebarSessionsSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className={rootClassName}>
|
<SidebarGroup className={rootClassName}>
|
||||||
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
|
<SidebarSectionHeader
|
||||||
|
action={headerAction}
|
||||||
|
icon={labelIcon}
|
||||||
|
label={label}
|
||||||
|
meta={labelMeta}
|
||||||
|
onToggle={onToggle}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
{open && (
|
{open && (
|
||||||
<SidebarGroupContent className={resolvedContentClassName}>
|
<SidebarGroupContent className={resolvedContentClassName}>
|
||||||
{body}
|
{body}
|
||||||
@@ -1398,30 +1500,3 @@ interface SortableSessionRowProps {
|
|||||||
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
||||||
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarLoadMoreRowProps {
|
|
||||||
loading: boolean
|
|
||||||
onClick: () => void
|
|
||||||
step: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={onClick}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{/* Seat the icon in the same w-3.5 column session rows use for their dot
|
|
||||||
so the chevron + label line up with the rows above. */}
|
|
||||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
|
||||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
|
||||||
</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
30
apps/desktop/src/app/chat/sidebar/load-more-row.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
|
interface SidebarLoadMoreRowProps {
|
||||||
|
step: number
|
||||||
|
onClick: () => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Load N more" affordance shared by the recents, messaging, and cron sections.
|
||||||
|
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
|
||||||
|
// lines up with the list above.
|
||||||
|
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||||
|
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -83,8 +83,9 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
|
|||||||
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
|
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
|
||||||
// left, the colored named profiles scrolling between, and Manage pinned right.
|
// left, the colored named profiles scrolling between, and Manage pinned right.
|
||||||
// The active profile pops in its own color — the "where am I" cue. Single-
|
// The active profile pops in its own color — the "where am I" cue. Single-
|
||||||
// profile users see only the "+" (create their first profile); everything else
|
// profile users see the "+" (create their first profile) and the Manage
|
||||||
// appears once a second profile exists.
|
// overflow (edit the default profile's SOUL.md); the colored named squares
|
||||||
|
// and the default↔all toggle only appear once a second profile exists.
|
||||||
export function ProfileRail() {
|
export function ProfileRail() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const p = t.profiles
|
const p = t.profiles
|
||||||
@@ -268,9 +269,11 @@ export function ProfileRail() {
|
|||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{multiProfile && (
|
{/* Always reachable, even with only the default profile: the manage
|
||||||
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
|
overlay is the only place to edit a profile's SOUL.md, and a
|
||||||
)}
|
single-profile user must be able to edit the default's persona
|
||||||
|
without first creating a throwaway second profile. */}
|
||||||
|
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||||
|
|
||||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||||
new-session reset), not stuck on the session you were just in. */}
|
new-session reset), not stuck on the session you were just in. */}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||||||
import { exportSession } from '@/lib/session-export'
|
import { exportSession } from '@/lib/session-export'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import { setSessions } from '@/store/session'
|
import { setSessions } from '@/store/session'
|
||||||
|
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||||
|
|
||||||
interface SessionActions {
|
interface SessionActions {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -68,6 +69,19 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
|||||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
...(canOpenSessionWindow()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
disabled: !sessionId,
|
||||||
|
icon: 'link-external',
|
||||||
|
label: r.newWindow,
|
||||||
|
onSelect: () => {
|
||||||
|
triggerHaptic('selection')
|
||||||
|
void openSessionInNewWindow(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
disabled: !sessionId,
|
disabled: !sessionId,
|
||||||
icon: 'cloud-download',
|
icon: 'cloud-download',
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import { useStore } from '@nanostores/react'
|
|||||||
import type * as React from 'react'
|
import type * as React from 'react'
|
||||||
|
|
||||||
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||||
|
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import type { SessionInfo } from '@/hermes'
|
import type { SessionInfo } from '@/hermes'
|
||||||
import { type Translations, useI18n } from '@/i18n'
|
import { type Translations, useI18n } from '@/i18n'
|
||||||
import { sessionTitle } from '@/lib/chat-runtime'
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
|
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $attentionSessionIds } from '@/store/session'
|
import { $attentionSessionIds } from '@/store/session'
|
||||||
|
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||||
|
|
||||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||||
|
|
||||||
@@ -67,6 +71,11 @@ export function SidebarSessionRow({
|
|||||||
const title = sessionTitle(session)
|
const title = sessionTitle(session)
|
||||||
const age = formatAge(session.last_active || session.started_at, r)
|
const age = formatAge(session.last_active || session.started_at, r)
|
||||||
const handleLabel = `Reorder ${title}`
|
const handleLabel = `Reorder ${title}`
|
||||||
|
// A handed-off session's live source is local, but it originated on a
|
||||||
|
// messaging platform — surface that origin as a small badge so e.g. a
|
||||||
|
// Telegram thread continued here still reads as Telegram.
|
||||||
|
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
|
||||||
|
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
|
||||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||||
// session is waiting on the user.
|
// session is waiting on the user.
|
||||||
@@ -124,11 +133,15 @@ export function SidebarSessionRow({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.metaKey || event.ctrlKey) {
|
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||||
|
// window — the universal "open in a new window" gesture. Archive
|
||||||
|
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||||
|
// normal resume when standalone windows aren't available (web embed).
|
||||||
|
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
onArchive()
|
void openSessionInNewWindow(session.id)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -179,6 +192,15 @@ export function SidebarSessionRow({
|
|||||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{handoffSource && handoffLabel ? (
|
||||||
|
<Tip label={r.handoffOrigin(handoffLabel)}>
|
||||||
|
<PlatformAvatar
|
||||||
|
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
|
||||||
|
platformId={handoffSource}
|
||||||
|
platformName={handoffLabel}
|
||||||
|
/>
|
||||||
|
</Tip>
|
||||||
|
) : null}
|
||||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||||
|
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
|
import { KbdGroup } from '@/components/ui/kbd'
|
||||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { sessionTitle } from '@/lib/chat-runtime'
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
@@ -12,11 +15,11 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Archive,
|
Archive,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Check,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
Download,
|
||||||
Globe,
|
Globe,
|
||||||
type IconComponent,
|
type IconComponent,
|
||||||
Info,
|
Info,
|
||||||
@@ -30,13 +33,18 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Settings2,
|
Settings2,
|
||||||
Sun,
|
Sun,
|
||||||
|
Terminal,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
Zap
|
Zap
|
||||||
} from '@/lib/icons'
|
} from '@/lib/icons'
|
||||||
|
import { comboTokens } from '@/lib/keybinds/combo'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||||
|
import { $bindings } from '@/store/keybinds'
|
||||||
|
import { luminance } from '@/themes/color'
|
||||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||||
|
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AGENTS_ROUTE,
|
AGENTS_ROUTE,
|
||||||
@@ -54,8 +62,11 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
|
|||||||
import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||||
import { prettyName } from '../settings/helpers'
|
import { prettyName } from '../settings/helpers'
|
||||||
|
|
||||||
|
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||||
|
|
||||||
interface PaletteItem {
|
interface PaletteItem {
|
||||||
active?: boolean
|
/** Keybind action id — its live combo renders as a hotkey hint. */
|
||||||
|
action?: string
|
||||||
icon: IconComponent
|
icon: IconComponent
|
||||||
id: string
|
id: string
|
||||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||||
@@ -69,10 +80,16 @@ interface PaletteItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PaletteGroup {
|
interface PaletteGroup {
|
||||||
heading: string
|
/** Optional: a headingless group renders as a bare action row (e.g. the
|
||||||
|
* "Install theme…" entry pinned atop the theme picker). */
|
||||||
|
heading?: string
|
||||||
items: PaletteItem[]
|
items: PaletteItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nested page → its parent, so Back / Esc step up one level instead of closing
|
||||||
|
// the palette. Pages absent here go straight back to the root list.
|
||||||
|
const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }
|
||||||
|
|
||||||
/** A nested page reachable from a root item via `to`. */
|
/** A nested page reachable from a root item via `to`. */
|
||||||
interface PalettePage {
|
interface PalettePage {
|
||||||
groups: PaletteGroup[]
|
groups: PaletteGroup[]
|
||||||
@@ -86,6 +103,22 @@ interface SessionEntry {
|
|||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
|
||||||
|
// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
|
||||||
|
// matching instead: every typed word must literally appear in the item's
|
||||||
|
// value/keywords, which keeps results tight and predictable.
|
||||||
|
const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
|
||||||
|
const needle = search.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!needle) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
|
||||||
|
|
||||||
|
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||||
|
|
||||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||||
@@ -146,11 +179,32 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
|
|||||||
{ icon: Monitor, mode: 'system' }
|
{ icon: Monitor, mode: 'system' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Which Light/Dark groups a theme belongs in. Built-ins render in both modes
|
||||||
|
// (the engine synthesises the missing side). Imported VS Code themes only carry
|
||||||
|
// the variant(s) the extension shipped — a single dark theme like Dracula lives
|
||||||
|
// under Dark only, while a GitHub/Solarized family (light + dark) lives in both.
|
||||||
|
function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
|
||||||
|
if (!isUserTheme(name)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveTheme(name)
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
|
||||||
|
|
||||||
|
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
|
||||||
|
}
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const open = useStore($commandPaletteOpen)
|
const open = useStore($commandPaletteOpen)
|
||||||
|
const bindings = useStore($bindings)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [page, setPage] = useState<string | null>(null)
|
const [page, setPage] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -194,10 +248,19 @@ export function CommandPalette() {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||||
|
|
||||||
|
// Step up one nested page (or back to the root list), clearing the filter so
|
||||||
|
// the parent page doesn't reopen mid-search.
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
setSearch('')
|
||||||
|
setPage(prev => (prev ? (PAGE_PARENTS[prev] ?? null) : null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const settingsSectionLabel = useCallback(
|
const settingsSectionLabel = useCallback(
|
||||||
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
|
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
|
||||||
[t.settings.sections]
|
[t.settings.sections]
|
||||||
)
|
)
|
||||||
|
|
||||||
const configFieldLabel = useCallback(
|
const configFieldLabel = useCallback(
|
||||||
(key: string) =>
|
(key: string) =>
|
||||||
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
|
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
|
||||||
@@ -214,20 +277,61 @@ export function CommandPalette() {
|
|||||||
{
|
{
|
||||||
heading: cc.goTo,
|
heading: cc.goTo,
|
||||||
items: [
|
items: [
|
||||||
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
|
|
||||||
{ icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
|
|
||||||
{
|
{
|
||||||
|
action: 'session.new',
|
||||||
|
icon: Plus,
|
||||||
|
id: 'nav-new',
|
||||||
|
keywords: ['chat', 'create'],
|
||||||
|
label: cc.nav.newChat.title,
|
||||||
|
run: go(NEW_CHAT_ROUTE)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'view.showTerminal',
|
||||||
|
icon: Terminal,
|
||||||
|
id: 'nav-terminal',
|
||||||
|
keywords: ['terminal', 'shell', 'console'],
|
||||||
|
label: t.keybinds.actions['view.showTerminal'],
|
||||||
|
run: () => setTerminalTakeover(true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'nav.settings',
|
||||||
|
icon: Settings,
|
||||||
|
id: 'nav-settings',
|
||||||
|
label: cc.nav.settings.title,
|
||||||
|
run: go(SETTINGS_ROUTE)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'nav.skills',
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
id: 'nav-skills',
|
id: 'nav-skills',
|
||||||
keywords: ['tools', 'toolsets'],
|
keywords: ['tools', 'toolsets'],
|
||||||
label: cc.nav.skills.title,
|
label: cc.nav.skills.title,
|
||||||
run: go(SKILLS_ROUTE)
|
run: go(SKILLS_ROUTE)
|
||||||
},
|
},
|
||||||
{ icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
|
{
|
||||||
{ icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
|
action: 'nav.messaging',
|
||||||
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
|
icon: MessageCircle,
|
||||||
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
id: 'nav-messaging',
|
||||||
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
label: cc.nav.messaging.title,
|
||||||
|
run: go(MESSAGING_ROUTE)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'nav.artifacts',
|
||||||
|
icon: Package,
|
||||||
|
id: 'nav-artifacts',
|
||||||
|
label: cc.nav.artifacts.title,
|
||||||
|
run: go(ARTIFACTS_ROUTE)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'nav.cron',
|
||||||
|
icon: Clock,
|
||||||
|
id: 'nav-cron',
|
||||||
|
keywords: ['schedule', 'jobs'],
|
||||||
|
label: t.shell.statusbar.cron,
|
||||||
|
run: go(CRON_ROUTE)
|
||||||
|
},
|
||||||
|
{ action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
||||||
|
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -373,24 +477,40 @@ export function CommandPalette() {
|
|||||||
theme: {
|
theme: {
|
||||||
title: t.settings.appearance.themeTitle,
|
title: t.settings.appearance.themeTitle,
|
||||||
placeholder: t.settings.appearance.themeDesc,
|
placeholder: t.settings.appearance.themeDesc,
|
||||||
// Skins aren't inherently light/dark — the same skin renders in either
|
groups: [
|
||||||
// mode. Group by appearance so picking an entry sets skin + mode at
|
// Pinned at the top: drills into the Marketplace browser.
|
||||||
// once, and keep the palette open so each pick previews live.
|
{
|
||||||
groups: (['light', 'dark'] as const).map(groupMode => ({
|
items: [
|
||||||
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
|
{
|
||||||
items: availableThemes.map(theme => ({
|
icon: Download,
|
||||||
active: themeName === theme.name && resolvedMode === groupMode,
|
id: 'theme-install',
|
||||||
icon: groupMode === 'light' ? Sun : Moon,
|
keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],
|
||||||
id: `theme-${theme.name}-${groupMode}`,
|
label: t.commandCenter.installTheme.title,
|
||||||
keepOpen: true,
|
to: 'install-theme'
|
||||||
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
|
}
|
||||||
label: theme.label,
|
]
|
||||||
run: () => {
|
},
|
||||||
setTheme(theme.name)
|
// Built-ins and imported families list under the mode(s) they support;
|
||||||
setMode(groupMode)
|
// picking sets skin + mode at once. A multi-variant import (GitHub,
|
||||||
}
|
// Solarized) appears in both groups and switches variants with the mode.
|
||||||
|
...(['light', 'dark'] as const).map(groupMode => ({
|
||||||
|
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
|
||||||
|
items: availableThemes
|
||||||
|
.filter(theme => themeSupportsMode(theme.name, groupMode))
|
||||||
|
.map(theme => ({
|
||||||
|
active: themeName === theme.name && resolvedMode === groupMode,
|
||||||
|
icon: groupMode === 'light' ? Sun : Moon,
|
||||||
|
id: `theme-${theme.name}-${groupMode}`,
|
||||||
|
keepOpen: true,
|
||||||
|
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
|
||||||
|
label: theme.label,
|
||||||
|
run: () => {
|
||||||
|
setTheme(theme.name)
|
||||||
|
setMode(groupMode)
|
||||||
|
}
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
}))
|
]
|
||||||
},
|
},
|
||||||
'color-mode': {
|
'color-mode': {
|
||||||
title: t.settings.appearance.colorMode,
|
title: t.settings.appearance.colorMode,
|
||||||
@@ -399,7 +519,6 @@ export function CommandPalette() {
|
|||||||
{
|
{
|
||||||
heading: t.settings.appearance.colorMode,
|
heading: t.settings.appearance.colorMode,
|
||||||
items: THEME_MODES.map(entry => ({
|
items: THEME_MODES.map(entry => ({
|
||||||
active: mode === entry.mode,
|
|
||||||
icon: entry.icon,
|
icon: entry.icon,
|
||||||
id: `mode-${entry.mode}`,
|
id: `mode-${entry.mode}`,
|
||||||
keepOpen: true,
|
keepOpen: true,
|
||||||
@@ -409,9 +528,16 @@ export function CommandPalette() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
// Server-driven page: items come from the Marketplace, rendered by
|
||||||
|
// <MarketplaceThemePage> (loader + live search + per-row install).
|
||||||
|
'install-theme': {
|
||||||
|
title: t.commandCenter.installTheme.title,
|
||||||
|
placeholder: t.commandCenter.installTheme.placeholder,
|
||||||
|
groups: []
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
|
[availableThemes, resolvedMode, setMode, setTheme, t, themeName]
|
||||||
)
|
)
|
||||||
|
|
||||||
const activePage = page ? subPages[page] : null
|
const activePage = page ? subPages[page] : null
|
||||||
@@ -436,17 +562,22 @@ export function CommandPalette() {
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 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]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
className={cn(
|
||||||
|
HUD_POSITION,
|
||||||
|
HUD_SURFACE,
|
||||||
|
'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 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]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||||
<Command className="bg-transparent" loop>
|
<Command className="bg-transparent" filter={paletteFilter} loop>
|
||||||
{activePage && (
|
{activePage && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
onClick={() => setPage(null)}
|
onClick={goBack}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-3.5" />
|
<ChevronLeft className="size-3.5" />
|
||||||
@@ -456,6 +587,7 @@ export function CommandPalette() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<CommandInput
|
<CommandInput
|
||||||
|
className={HUD_TEXT}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (!activePage) {
|
if (!activePage) {
|
||||||
return
|
return
|
||||||
@@ -466,38 +598,45 @@ export function CommandPalette() {
|
|||||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setPage(null)
|
goBack()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
{page === 'install-theme' ? (
|
||||||
{visibleGroups.map(group => (
|
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||||
|
) : (
|
||||||
|
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{visibleGroups.map((group, index) => (
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
className={HUD_HEADING}
|
||||||
heading={group.heading}
|
heading={group.heading}
|
||||||
key={group.heading}
|
key={group.heading ?? `palette-group-${index}`}
|
||||||
>
|
>
|
||||||
{group.items.map(item => {
|
{group.items.map(item => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
|
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||||
|
const keys = combo ? comboTokens(combo) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
className="gap-2.5"
|
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
keywords={item.keywords}
|
keywords={item.keywords}
|
||||||
onSelect={() => handleSelect(item)}
|
onSelect={() => handleSelect(item)}
|
||||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
{item.to ? (
|
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
|
{item.to && (
|
||||||
) : (
|
<ChevronRight
|
||||||
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
|
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
|
|||||||
157
apps/desktop/src/app/command-palette/marketplace-theme-page.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Cmd-K "Install theme…" page.
|
||||||
|
*
|
||||||
|
* Browses the VS Code Marketplace for color themes: an empty query shows the
|
||||||
|
* most-installed themes, typing runs a live (debounced) search against the
|
||||||
|
* Marketplace. Selecting a row downloads + converts + installs it via the same
|
||||||
|
* pipeline as the settings importer, then activates it — and stays open so the
|
||||||
|
* user can grab several.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
|
||||||
|
import type { DesktopMarketplaceSearchItem } from '@/global'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
|
import { Check, Download, Loader2, Palette } from '@/lib/icons'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||||
|
|
||||||
|
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketplaceThemePageProps {
|
||||||
|
search: string
|
||||||
|
/** Activate a freshly installed theme by slug. */
|
||||||
|
onPickTheme: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.commandCenter.installTheme
|
||||||
|
const debouncedSearch = useDebounced(search.trim(), 300)
|
||||||
|
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||||
|
const [installed, setInstalled] = useState<Record<string, true>>({})
|
||||||
|
const [installError, setInstallError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['marketplace-themes', debouncedSearch],
|
||||||
|
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),
|
||||||
|
staleTime: 5 * 60 * 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||||
|
if (installingId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setInstallingId(item.extensionId)
|
||||||
|
setInstallError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||||
|
|
||||||
|
triggerHaptic('crisp')
|
||||||
|
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
|
||||||
|
onPickTheme(theme.name)
|
||||||
|
} catch (error) {
|
||||||
|
setInstallError(error instanceof Error ? error.message : copy.error)
|
||||||
|
} finally {
|
||||||
|
setInstallingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return <Status text={copy.error} tone="error" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = query.data ?? []
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return <Status text={copy.empty} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="listbox">
|
||||||
|
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
|
||||||
|
{results.map(item => {
|
||||||
|
const busy = installingId === item.extensionId
|
||||||
|
const done = installed[item.extensionId]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-start rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60',
|
||||||
|
HUD_ITEM,
|
||||||
|
HUD_TEXT
|
||||||
|
)}
|
||||||
|
disabled={Boolean(installingId) && !busy}
|
||||||
|
key={item.extensionId}
|
||||||
|
onClick={() => void install(item)}
|
||||||
|
onMouseDown={event => event.preventDefault()}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Palette className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate font-medium">{item.displayName}</span>
|
||||||
|
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||||
|
{item.publisher}
|
||||||
|
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto mt-0.5 flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground">
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
{copy.installing}
|
||||||
|
</>
|
||||||
|
) : done ? (
|
||||||
|
<>
|
||||||
|
<Check className="size-3 text-(--ui-green)" />
|
||||||
|
{copy.installed}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="size-3" />
|
||||||
|
{copy.install}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
|||||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||||
|
import {
|
||||||
|
isMessagingSource,
|
||||||
|
LOCAL_SESSION_SOURCE_IDS,
|
||||||
|
MESSAGING_SESSION_SOURCE_IDS,
|
||||||
|
normalizeSessionSource
|
||||||
|
} from '../lib/session-source'
|
||||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||||
import {
|
import {
|
||||||
$panesFlipped,
|
$panesFlipped,
|
||||||
@@ -44,12 +50,14 @@ import {
|
|||||||
$currentCwd,
|
$currentCwd,
|
||||||
$freshDraftReady,
|
$freshDraftReady,
|
||||||
$gatewayState,
|
$gatewayState,
|
||||||
|
$messagingSessions,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessions,
|
$sessions,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
CRON_SECTION_LIMIT,
|
CRON_SECTION_LIMIT,
|
||||||
getRecentlySettledSessionIds,
|
getRecentlySettledSessionIds,
|
||||||
mergeSessionPage,
|
mergeSessionPage,
|
||||||
|
MESSAGING_SECTION_LIMIT,
|
||||||
sessionPinId,
|
sessionPinId,
|
||||||
setAwaitingResponse,
|
setAwaitingResponse,
|
||||||
setBusy,
|
setBusy,
|
||||||
@@ -59,12 +67,16 @@ import {
|
|||||||
setCurrentModel,
|
setCurrentModel,
|
||||||
setCurrentProvider,
|
setCurrentProvider,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
setMessagingPlatformTotals,
|
||||||
|
setMessagingSessions,
|
||||||
|
setMessagingTruncated,
|
||||||
setSessionProfileTotals,
|
setSessionProfileTotals,
|
||||||
setSessions,
|
setSessions,
|
||||||
setSessionsLoading,
|
setSessionsLoading,
|
||||||
setSessionsTotal
|
setSessionsTotal
|
||||||
} from '../store/session'
|
} from '../store/session'
|
||||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||||
|
import { isSecondaryWindow } from '../store/windows'
|
||||||
|
|
||||||
import { ChatView } from './chat'
|
import { ChatView } from './chat'
|
||||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||||
@@ -86,6 +98,8 @@ import { RightSidebarPane } from './right-sidebar'
|
|||||||
import { $terminalTakeover } from './right-sidebar/store'
|
import { $terminalTakeover } from './right-sidebar/store'
|
||||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||||
|
import { SessionPickerOverlay } from './session-picker-overlay'
|
||||||
|
import { SessionSwitcher } from './session-switcher'
|
||||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||||
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
||||||
@@ -121,11 +135,22 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
|
|||||||
// this cadence while the app is open + visible so new runs surface promptly
|
// this cadence while the app is open + visible so new runs surface promptly
|
||||||
// instead of waiting for the next user-triggered refreshSessions().
|
// instead of waiting for the next user-triggered refreshSessions().
|
||||||
const CRON_POLL_INTERVAL_MS = 30_000
|
const CRON_POLL_INTERVAL_MS = 30_000
|
||||||
|
// The recents list is local-only: cron rows have their own section, and each
|
||||||
|
// messaging platform (telegram, discord, …) is fetched separately into its own
|
||||||
|
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
|
||||||
|
// keeps "Load more" paging through interactive local chats instead of
|
||||||
|
// interleaving gateway threads that bury them.
|
||||||
|
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
|
||||||
|
// The messaging slice is the inverse: drop cron + every local source so only
|
||||||
|
// external-platform conversations remain, then split per platform in the UI.
|
||||||
|
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
|
||||||
|
|
||||||
// Cheap signature compare so the poll only swaps the atom (and re-renders the
|
// Cheap signature compare so the poll only swaps the atom (and re-renders the
|
||||||
// sidebar) when the visible cron rows actually changed.
|
// sidebar) when the visible cron rows actually changed.
|
||||||
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||||
if (a.length !== b.length) {return false}
|
if (a.length !== b.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
|
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
|
||||||
}
|
}
|
||||||
@@ -201,7 +226,7 @@ export function DesktopController() {
|
|||||||
toggleCommandCenter
|
toggleCommandCenter
|
||||||
} = useOverlayRouting()
|
} = useOverlayRouting()
|
||||||
|
|
||||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
const terminalSidebarOpen = chatOpen && terminalTakeover
|
||||||
|
|
||||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||||
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
||||||
@@ -280,6 +305,51 @@ export function DesktopController() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Messaging-platform sessions as their own slice, fetched separately from
|
||||||
|
// local recents so each platform renders a self-managed section and never
|
||||||
|
// competes with local chats for the recents page budget. One combined fetch
|
||||||
|
// seeds every platform; the sidebar splits the rows per source.
|
||||||
|
const refreshMessagingSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
|
||||||
|
excludeSources: MESSAGING_EXCLUDED_SOURCES
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drop any non-messaging source the broad exclude didn't catch (custom
|
||||||
|
// sources) — those stay in local recents, not a platform section.
|
||||||
|
const rows = result.sessions.filter(s => isMessagingSource(s.source))
|
||||||
|
|
||||||
|
setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
|
||||||
|
// Hit the cap → at least one platform may have more on disk than loaded,
|
||||||
|
// so platform sections offer their own per-platform "load more".
|
||||||
|
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: the messaging sections just stay empty/stale.
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Page a single platform's section independently (mirrors the per-profile
|
||||||
|
// pager): fetch that source's next window and merge it back in place, leaving
|
||||||
|
// every other platform's rows untouched. Resolves the platform's exact total.
|
||||||
|
const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
|
||||||
|
const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
|
||||||
|
const loaded = $messagingSessions.get().filter(inPlatform).length
|
||||||
|
|
||||||
|
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
|
||||||
|
source: platform
|
||||||
|
})
|
||||||
|
|
||||||
|
const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
|
||||||
|
|
||||||
|
setMessagingSessions(prev => [
|
||||||
|
...prev.filter(s => !inPlatform(s)),
|
||||||
|
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = result.total ?? incoming.length
|
||||||
|
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
|
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
|
||||||
// synchronously (agent tool call or the cron UI), so refreshing here right
|
// synchronously (agent tool call or the cron UI), so refreshing here right
|
||||||
// after an agent turn surfaces a new job immediately; the interval poll keeps
|
// after an agent turn surfaces a new job immediately; the interval poll keeps
|
||||||
@@ -316,7 +386,7 @@ export function DesktopController() {
|
|||||||
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
||||||
|
|
||||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
||||||
excludeSources: ['cron']
|
excludeSources: SIDEBAR_EXCLUDED_SOURCES
|
||||||
})
|
})
|
||||||
|
|
||||||
if (refreshSessionsRequestRef.current === requestId) {
|
if (refreshSessionsRequestRef.current === requestId) {
|
||||||
@@ -332,7 +402,8 @@ export function DesktopController() {
|
|||||||
|
|
||||||
void refreshCronSessions()
|
void refreshCronSessions()
|
||||||
void refreshCronJobs()
|
void refreshCronJobs()
|
||||||
}, [profileScope, refreshCronSessions, refreshCronJobs])
|
void refreshMessagingSessions()
|
||||||
|
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||||
|
|
||||||
const loadMoreSessions = useCallback(() => {
|
const loadMoreSessions = useCallback(() => {
|
||||||
bumpSessionsLimit()
|
bumpSessionsLimit()
|
||||||
@@ -347,12 +418,15 @@ export function DesktopController() {
|
|||||||
const loaded = $sessions.get().filter(inKey).length
|
const loaded = $sessions.get().filter(inKey).length
|
||||||
|
|
||||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
|
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
|
||||||
excludeSources: ['cron']
|
excludeSources: SIDEBAR_EXCLUDED_SOURCES
|
||||||
})
|
})
|
||||||
|
|
||||||
const keep = sessionsToKeep(key)
|
const keep = sessionsToKeep(key)
|
||||||
|
|
||||||
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
|
setSessions(prev => [
|
||||||
|
...prev.filter(s => !inKey(s)),
|
||||||
|
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
|
||||||
|
])
|
||||||
|
|
||||||
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
||||||
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
||||||
@@ -613,19 +687,20 @@ export function DesktopController() {
|
|||||||
submitText,
|
submitText,
|
||||||
transcribeVoiceAudio
|
transcribeVoiceAudio
|
||||||
} = usePromptActions({
|
} = usePromptActions({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
branchCurrentSession: branchInNewChat,
|
branchCurrentSession: branchInNewChat,
|
||||||
busyRef,
|
busyRef,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
handleSkinCommand,
|
handleSkinCommand,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionIdRef,
|
resumeStoredSession: resumeSession,
|
||||||
startFreshSessionDraft,
|
selectedStoredSessionIdRef,
|
||||||
sttEnabled,
|
startFreshSessionDraft,
|
||||||
updateSessionState
|
sttEnabled,
|
||||||
})
|
updateSessionState
|
||||||
|
})
|
||||||
|
|
||||||
useGatewayBoot({
|
useGatewayBoot({
|
||||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||||
@@ -651,10 +726,14 @@ export function DesktopController() {
|
|||||||
// in the background (advancing next-run/state and creating runs), so poll the
|
// in the background (advancing next-run/state and creating runs), so poll the
|
||||||
// job list on an interval (and on tab re-focus) while connected.
|
// job list on an interval (and on tab re-focus) while connected.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gatewayState !== 'open') {return}
|
if (gatewayState !== 'open') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (document.visibilityState === 'visible') {void refreshCronJobs()}
|
if (document.visibilityState === 'visible') {
|
||||||
|
void refreshCronJobs()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
||||||
@@ -666,6 +745,13 @@ export function DesktopController() {
|
|||||||
}
|
}
|
||||||
}, [gatewayState, refreshCronJobs])
|
}, [gatewayState, refreshCronJobs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
|
||||||
|
void refreshCurrentModel()
|
||||||
|
void refreshHermesConfig()
|
||||||
|
}
|
||||||
|
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
|
||||||
|
|
||||||
useRouteResume({
|
useRouteResume({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
@@ -684,6 +770,7 @@ export function DesktopController() {
|
|||||||
|
|
||||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||||
agentsOpen,
|
agentsOpen,
|
||||||
|
chatOpen,
|
||||||
commandCenterOpen,
|
commandCenterOpen,
|
||||||
extraLeftItems: statusbarItemGroups.flat.left,
|
extraLeftItems: statusbarItemGroups.flat.left,
|
||||||
extraRightItems: statusbarItemGroups.flat.right,
|
extraRightItems: statusbarItemGroups.flat.right,
|
||||||
@@ -704,6 +791,7 @@ export function DesktopController() {
|
|||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||||
|
onLoadMoreMessaging={loadMoreMessagingForPlatform}
|
||||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||||
onLoadMoreSessions={loadMoreSessions}
|
onLoadMoreSessions={loadMoreSessions}
|
||||||
onManageCronJob={jobId => {
|
onManageCronJob={jobId => {
|
||||||
@@ -721,27 +809,35 @@ export function DesktopController() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
|
||||||
|
// where it shows. Lives in main's stacking context (not the root overlay layer)
|
||||||
|
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
|
||||||
|
const mainOverlays = (
|
||||||
|
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||||
|
)
|
||||||
|
|
||||||
const overlays = (
|
const overlays = (
|
||||||
<>
|
<>
|
||||||
<DesktopInstallOverlay />
|
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||||
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
|
{!isSecondaryWindow() && (
|
||||||
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
|
<DesktopOnboardingOverlay
|
||||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
enabled={gatewayState === 'open'}
|
||||||
<DesktopOnboardingOverlay
|
onCompleted={() => {
|
||||||
enabled={gatewayState === 'open'}
|
void refreshHermesConfig()
|
||||||
onCompleted={() => {
|
void refreshCurrentModel()
|
||||||
void refreshHermesConfig()
|
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||||
void refreshCurrentModel()
|
}}
|
||||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
requestGateway={requestGateway}
|
||||||
}}
|
/>
|
||||||
requestGateway={requestGateway}
|
)}
|
||||||
/>
|
|
||||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||||
|
<SessionPickerOverlay onResume={resumeSession} />
|
||||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||||
<UpdatesOverlay />
|
<UpdatesOverlay />
|
||||||
<GatewayConnectingOverlay />
|
<GatewayConnectingOverlay />
|
||||||
<BootFailureOverlay />
|
<BootFailureOverlay />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<SessionSwitcher />
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -829,12 +925,6 @@ export function DesktopController() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const takeoverTerminalView = (
|
|
||||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)">
|
|
||||||
<TerminalSlot />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flipped layout mirrors the default: sessions sidebar → right, file
|
// Flipped layout mirrors the default: sessions sidebar → right, file
|
||||||
// browser + preview rail → left. Same panes, swapped sides.
|
// browser + preview rail → left. Same panes, swapped sides.
|
||||||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||||
@@ -879,33 +969,56 @@ export function DesktopController() {
|
|||||||
</Pane>
|
</Pane>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const terminalPane = (
|
||||||
|
<Pane
|
||||||
|
defaultOpen
|
||||||
|
disabled={!terminalSidebarOpen}
|
||||||
|
divider
|
||||||
|
id="terminal-sidebar"
|
||||||
|
key="terminal-sidebar"
|
||||||
|
maxWidth="80vw"
|
||||||
|
minWidth="22vw"
|
||||||
|
resizable
|
||||||
|
side={railSide}
|
||||||
|
width="42vw"
|
||||||
|
>
|
||||||
|
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
|
||||||
|
<TerminalSlot />
|
||||||
|
</div>
|
||||||
|
</Pane>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
leftStatusbarItems={leftStatusbarItems}
|
leftStatusbarItems={leftStatusbarItems}
|
||||||
leftTitlebarTools={titlebarToolGroups.flat.left}
|
leftTitlebarTools={titlebarToolGroups.flat.left}
|
||||||
|
mainOverlays={mainOverlays}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
overlays={overlays}
|
overlays={overlays}
|
||||||
|
previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)}
|
||||||
statusbarItems={statusbarItems}
|
statusbarItems={statusbarItems}
|
||||||
|
terminalPaneOpen={terminalSidebarOpen}
|
||||||
titlebarTools={titlebarToolGroups.flat.right}
|
titlebarTools={titlebarToolGroups.flat.right}
|
||||||
>
|
>
|
||||||
<Pane
|
{!isSecondaryWindow() && (
|
||||||
disabled={terminalTakeoverActive}
|
<Pane
|
||||||
forceCollapsed={narrowViewport}
|
forceCollapsed={narrowViewport}
|
||||||
hoverReveal
|
hoverReveal
|
||||||
id="chat-sidebar"
|
id="chat-sidebar"
|
||||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||||
onOverlayActiveChange={setSidebarOverlayMounted}
|
onOverlayActiveChange={setSidebarOverlayMounted}
|
||||||
resizable
|
resizable
|
||||||
side={sidebarSide}
|
side={sidebarSide}
|
||||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||||
>
|
>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
</Pane>
|
</Pane>
|
||||||
|
)}
|
||||||
<PaneMain>
|
<PaneMain>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
|
<Route element={chatView} index />
|
||||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
|
<Route element={chatView} path=":sessionId" />
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -942,11 +1055,13 @@ export function DesktopController() {
|
|||||||
</PaneMain>
|
</PaneMain>
|
||||||
{/*
|
{/*
|
||||||
Order within a side maps to column order. Default (rail on the right):
|
Order within a side maps to column order. Default (rail on the right):
|
||||||
main | preview | file-browser. Flipped (rail on the left): mirror it to
|
main | terminal | preview | file-browser. Flipped (rail on the left):
|
||||||
file-browser | preview | main so preview stays adjacent to the chat.
|
mirror to file-browser | preview | terminal | main so terminal stays
|
||||||
|
adjacent to the chat.
|
||||||
*/}
|
*/}
|
||||||
{panesFlipped ? fileBrowserPane : previewPane}
|
{panesFlipped ? fileBrowserPane : terminalPane}
|
||||||
{panesFlipped ? previewPane : fileBrowserPane}
|
{previewPane}
|
||||||
|
{panesFlipped ? terminalPane : fileBrowserPane}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/desktop/src/app/floating-hud.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Shared chrome for the top-center floating HUDs (command palette + session
|
||||||
|
// switcher). They pin just under the title bar, centered, and lean on a crisp
|
||||||
|
// border + shadow to separate from the app — no dimming/blurring backdrop.
|
||||||
|
// Each caller layers on its own z-index, width, and overflow.
|
||||||
|
export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'
|
||||||
|
|
||||||
|
// Matches the app's borderless-overlay surface (dialog, keybind panel, …):
|
||||||
|
// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.
|
||||||
|
export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous'
|
||||||
|
|
||||||
|
// One row/text size for both HUDs (compact — two notches under `text-sm`).
|
||||||
|
export const HUD_TEXT = 'text-xs'
|
||||||
|
|
||||||
|
// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows
|
||||||
|
// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`).
|
||||||
|
export const HUD_ITEM = 'gap-2 px-2 py-1'
|
||||||
|
|
||||||
|
// Section headings styled like the sidebar panel labels: brand-tinted, uppercase,
|
||||||
|
// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group
|
||||||
|
// heading via the universal-descendant variant.
|
||||||
|
export const HUD_HEADING =
|
||||||
|
'**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)'
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
$connection,
|
$connection,
|
||||||
$sessions,
|
$sessions,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
|
ensureDefaultWorkspaceCwd,
|
||||||
setConnection,
|
setConnection,
|
||||||
setSessionsLoading
|
setSessionsLoading
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
@@ -351,6 +352,7 @@ export function useGatewayBoot({
|
|||||||
message: translateNow('boot.steps.loadingSettings'),
|
message: translateNow('boot.steps.loadingSettings'),
|
||||||
progress: 97
|
progress: 97
|
||||||
})
|
})
|
||||||
|
await ensureDefaultWorkspaceCwd()
|
||||||
await callbacksRef.current.refreshHermesConfig()
|
await callbacksRef.current.refreshHermesConfig()
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||||
import { matchesQuery } from '@/hooks/use-media-query'
|
import { matchesQuery } from '@/hooks/use-media-query'
|
||||||
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
|
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||||
import { toggleCommandPalette } from '@/store/command-palette'
|
import { toggleCommandPalette } from '@/store/command-palette'
|
||||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||||
@@ -18,13 +18,25 @@ import {
|
|||||||
toggleSidebarOpen
|
toggleSidebarOpen
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import {
|
import {
|
||||||
|
$newChatProfile,
|
||||||
cycleProfile,
|
cycleProfile,
|
||||||
requestProfileCreate,
|
requestProfileCreate,
|
||||||
switchProfileToSlot,
|
switchProfileToSlot,
|
||||||
switchToDefaultProfile,
|
switchToDefaultProfile,
|
||||||
toggleShowAllProfiles
|
toggleShowAllProfiles
|
||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
|
import { setModelPickerOpen } from '@/store/session'
|
||||||
|
import {
|
||||||
|
$switcherOpen,
|
||||||
|
closeSwitcher,
|
||||||
|
commitOnCtrlUp,
|
||||||
|
onSwitcherTabDown,
|
||||||
|
onSwitcherTabUp,
|
||||||
|
openOrAdvanceSwitcher,
|
||||||
|
slotSessionId,
|
||||||
|
switcherActive,
|
||||||
|
switcherJustClosed
|
||||||
|
} from '@/store/session-switcher'
|
||||||
import { useTheme } from '@/themes/context'
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
import { requestComposerFocus } from '../chat/composer/focus'
|
import { requestComposerFocus } from '../chat/composer/focus'
|
||||||
@@ -60,6 +72,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
|
|
||||||
// Keep the latest closures without re-subscribing the listener.
|
// Keep the latest closures without re-subscribing the listener.
|
||||||
const handlersRef = useRef<HandlerMap>({})
|
const handlersRef = useRef<HandlerMap>({})
|
||||||
|
const commitSwitcherRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
const profileSwitchHandlers: HandlerMap = {}
|
const profileSwitchHandlers: HandlerMap = {}
|
||||||
|
|
||||||
@@ -67,26 +80,32 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to the adjacent session in recency order, wrapping at the ends.
|
const goToSession = (sessionId: null | string) => {
|
||||||
const cycleSession = (direction: 1 | -1) => {
|
if (sessionId) {
|
||||||
const sessions = $sessions.get()
|
navigate(sessionRoute(sessionId))
|
||||||
|
|
||||||
if (sessions.length < 2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = sessions.findIndex(session => session.id === $activeSessionId.get())
|
|
||||||
const start = current === -1 ? (direction === 1 ? -1 : 0) : current
|
|
||||||
const next = sessions[(start + direction + sessions.length) % sessions.length]
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
navigate(sessionRoute(next.id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
// ^N jumps straight to the Nth recent session and dismisses the switcher.
|
||||||
|
const sessionSlotHandlers: HandlerMap = {}
|
||||||
|
|
||||||
|
for (let slot = 1; slot <= SESSION_SLOT_COUNT; slot += 1) {
|
||||||
|
sessionSlotHandlers[`session.slot.${slot}`] = () => {
|
||||||
|
closeSwitcher()
|
||||||
|
goToSession(slotSessionId(slot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commitSwitcherRef.current = () => goToSession(commitOnCtrlUp())
|
||||||
|
|
||||||
|
const stepSession = (direction: 1 | -1) => {
|
||||||
|
onSwitcherTabDown()
|
||||||
|
goToSession(openOrAdvanceSwitcher(direction))
|
||||||
|
}
|
||||||
|
|
||||||
|
const showFiles = () => {
|
||||||
setFileBrowserOpen(true)
|
setFileBrowserOpen(true)
|
||||||
setRightSidebarTab(tab)
|
setTerminalTakeover(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
handlersRef.current = {
|
handlersRef.current = {
|
||||||
@@ -106,11 +125,16 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
'nav.agents': () => navigate(AGENTS_ROUTE),
|
'nav.agents': () => navigate(AGENTS_ROUTE),
|
||||||
|
|
||||||
'session.new': () => {
|
'session.new': () => {
|
||||||
|
// Match the sidebar New Session button. A plain keyboard new chat should
|
||||||
|
// target the current live profile, not a stale per-profile quick-create
|
||||||
|
// selection from a prior action.
|
||||||
|
$newChatProfile.set(null)
|
||||||
deps.startFreshSession()
|
deps.startFreshSession()
|
||||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||||
},
|
},
|
||||||
'session.next': () => cycleSession(1),
|
'session.next': () => stepSession(1),
|
||||||
'session.prev': () => cycleSession(-1),
|
'session.prev': () => stepSession(-1),
|
||||||
|
...sessionSlotHandlers,
|
||||||
'session.focusSearch': requestSessionSearchFocus,
|
'session.focusSearch': requestSessionSearchFocus,
|
||||||
'session.togglePin': deps.toggleSelectedPin,
|
'session.togglePin': deps.toggleSelectedPin,
|
||||||
|
|
||||||
@@ -128,8 +152,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
toggleFileBrowserOpen()
|
toggleFileBrowserOpen()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'view.showFiles': () => showRightSidebarTab('files'),
|
'view.showFiles': showFiles,
|
||||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||||
'view.flipPanes': togglePanesFlipped,
|
'view.flipPanes': togglePanesFlipped,
|
||||||
|
|
||||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||||
@@ -170,6 +194,16 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While the session switcher is up, Esc abandons it (stay put) before any
|
||||||
|
// combo dispatch — ⌃Tab keeps stepping through the existing handler.
|
||||||
|
if (switcherActive() && event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
closeSwitcher()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const combo = comboFromEvent(event)
|
const combo = comboFromEvent(event)
|
||||||
|
|
||||||
if (!combo) {
|
if (!combo) {
|
||||||
@@ -196,8 +230,39 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
handler()
|
handler()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
// Mac-app-switcher commit: lifting Ctrl with the overlay open lands on the
|
||||||
|
// highlighted session. A window blur (Cmd+Tab away mid-switch) cancels so
|
||||||
|
// the overlay never gets stranded waiting for a keyup that never comes.
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
onSwitcherTabUp()
|
||||||
|
}
|
||||||
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
if (event.key === 'Control') {
|
||||||
|
commitSwitcherRef.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBlur = () => switcherActive() && closeSwitcher()
|
||||||
|
|
||||||
|
// Swallow trailing contextmenu after Ctrl+click commit (Electron main menu).
|
||||||
|
const onContextMenu = (event: MouseEvent) => {
|
||||||
|
if ($switcherOpen.get() || switcherJustClosed()) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||||
|
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||||
|
window.addEventListener('blur', onBlur)
|
||||||
|
window.addEventListener('contextmenu', onContextMenu, { capture: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||||
|
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||||
|
window.removeEventListener('blur', onBlur)
|
||||||
|
window.removeEventListener('contextmenu', onContextMenu, { capture: true })
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,20 @@ import type { ReactNode } from 'react'
|
|||||||
import { ErrorBoundary } from '@/components/error-boundary'
|
import { ErrorBoundary } from '@/components/error-boundary'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { useI18n } from '@/i18n'
|
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $panesFlipped } from '@/store/layout'
|
import { $panesFlipped } from '@/store/layout'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
import { $currentCwd } from '@/store/session'
|
||||||
|
|
||||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||||
|
|
||||||
import { ProjectTree } from './files/tree'
|
import { ProjectTree } from './files/tree'
|
||||||
import { useProjectTree } from './files/use-project-tree'
|
import { useProjectTree } from './files/use-project-tree'
|
||||||
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
|
|
||||||
import { TerminalSlot } from './terminal/persistent'
|
|
||||||
|
|
||||||
interface RightSidebarPaneProps {
|
interface RightSidebarPaneProps {
|
||||||
onActivateFile: (path: string) => void
|
onActivateFile: (path: string) => void
|
||||||
@@ -27,24 +25,10 @@ interface RightSidebarPaneProps {
|
|||||||
onChangeCwd: (path: string) => Promise<void> | void
|
onChangeCwd: (path: string) => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RightSidebarTab {
|
|
||||||
icon: string
|
|
||||||
id: RightSidebarTabId
|
|
||||||
labelKey: 'files' | 'terminal'
|
|
||||||
}
|
|
||||||
|
|
||||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
|
||||||
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
|
|
||||||
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const r = t.rightSidebar
|
const r = t.rightSidebar
|
||||||
const activeTab = useStore($rightSidebarTab)
|
|
||||||
const terminalTakeover = useStore($terminalTakeover)
|
|
||||||
const panesFlipped = useStore($panesFlipped)
|
const panesFlipped = useStore($panesFlipped)
|
||||||
const currentBranch = useStore($currentBranch).trim()
|
|
||||||
const currentCwd = useStore($currentCwd).trim()
|
const currentCwd = useStore($currentCwd).trim()
|
||||||
const hasCwd = currentCwd.length > 0
|
const hasCwd = currentCwd.length > 0
|
||||||
|
|
||||||
@@ -68,7 +52,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
} = useProjectTree(currentCwd)
|
} = useProjectTree(currentCwd)
|
||||||
|
|
||||||
const canCollapse = Object.values(openState).some(Boolean)
|
const canCollapse = Object.values(openState).some(Boolean)
|
||||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
|
||||||
|
|
||||||
const chooseFolder = async () => {
|
const chooseFolder = async () => {
|
||||||
const selected = await window.hermesDesktop?.selectPaths({
|
const selected = await window.hermesDesktop?.selectPaths({
|
||||||
@@ -97,8 +80,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label={r.aria}
|
aria-label={r.aria}
|
||||||
@@ -109,85 +90,29 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
<FilesystemTab
|
||||||
|
canCollapse={canCollapse}
|
||||||
{effectiveTab === 'terminal' ? (
|
collapseNonce={collapseNonce}
|
||||||
<TerminalSlot />
|
cwd={currentCwd}
|
||||||
) : (
|
cwdName={cwdName}
|
||||||
<FilesystemTab
|
data={data}
|
||||||
canCollapse={canCollapse}
|
error={rootError}
|
||||||
collapseNonce={collapseNonce}
|
hasCwd={hasCwd}
|
||||||
cwd={currentCwd}
|
loading={rootLoading}
|
||||||
cwdName={cwdName}
|
onActivateFile={onActivateFile}
|
||||||
data={data}
|
onActivateFolder={onActivateFolder}
|
||||||
error={rootError}
|
onChangeFolder={chooseFolder}
|
||||||
hasCwd={hasCwd}
|
onCollapseAll={collapseAll}
|
||||||
loading={rootLoading}
|
onLoadChildren={loadChildren}
|
||||||
onActivateFile={onActivateFile}
|
onNodeOpenChange={setNodeOpen}
|
||||||
onActivateFolder={onActivateFolder}
|
onPreviewFile={previewFile}
|
||||||
onChangeFolder={chooseFolder}
|
onRefresh={() => void refreshRoot()}
|
||||||
onCollapseAll={collapseAll}
|
openState={openState}
|
||||||
onLoadChildren={loadChildren}
|
/>
|
||||||
onNodeOpenChange={setNodeOpen}
|
|
||||||
onPreviewFile={previewFile}
|
|
||||||
onRefresh={() => void refreshRoot()}
|
|
||||||
openState={openState}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RightSidebarChrome({
|
|
||||||
activeTab,
|
|
||||||
branch,
|
|
||||||
tabs
|
|
||||||
}: {
|
|
||||||
activeTab: RightSidebarTabId
|
|
||||||
branch: string
|
|
||||||
tabs: readonly RightSidebarTab[]
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const r = t.rightSidebar
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
|
||||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
|
||||||
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
|
|
||||||
{tabs.map(tab => {
|
|
||||||
const label = r[tab.labelKey]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tip key={tab.id} label={label}>
|
|
||||||
<Button
|
|
||||||
aria-label={label}
|
|
||||||
aria-pressed={tab.id === activeTab}
|
|
||||||
className={cn(
|
|
||||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
|
||||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() => setRightSidebarTab(tab.id)}
|
|
||||||
size="icon-xs"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Codicon name={tab.icon} size="0.875rem" />
|
|
||||||
</Button>
|
|
||||||
</Tip>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{branch && (
|
|
||||||
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
|
||||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
|
||||||
<span className="truncate">{branch}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||||
canCollapse: boolean
|
canCollapse: boolean
|
||||||
cwdName: string
|
cwdName: string
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { atom } from 'nanostores'
|
|||||||
|
|
||||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||||
|
|
||||||
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
|
|
||||||
|
|
||||||
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
|
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
|
||||||
|
|
||||||
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
|
|
||||||
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||||
|
|
||||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||||
|
|
||||||
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
|
|
||||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||||
|
|||||||
65
apps/desktop/src/app/right-sidebar/terminal/buffer.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Terminal } from '@xterm/xterm'
|
||||||
|
|
||||||
|
// Serialized view of the in-app terminal, handed to the agent's `read_terminal`
|
||||||
|
// tool. Line indices are absolute into xterm's buffer (0 = oldest scrollback
|
||||||
|
// line), so the agent can page with start_line/count against `total_lines`.
|
||||||
|
export interface TerminalReadResult {
|
||||||
|
total_lines: number
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
viewport_rows: number
|
||||||
|
cursor_row: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalReadOptions {
|
||||||
|
start?: number
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reader = (opts: TerminalReadOptions) => TerminalReadResult
|
||||||
|
|
||||||
|
// The persistent terminal is a singleton (one xterm mounted forever), so a
|
||||||
|
// module-level slot is enough — set while the session is live, cleared on
|
||||||
|
// dispose. The gateway `terminal.read.request` handler reads through this.
|
||||||
|
let activeReader: Reader | null = null
|
||||||
|
|
||||||
|
export function setActiveTerminalReader(reader: Reader | null): void {
|
||||||
|
activeReader = reader
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null {
|
||||||
|
return activeReader ? activeReader(opts) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeTerminalReader(term: Terminal): Reader {
|
||||||
|
return ({ start, count }) => {
|
||||||
|
const buf = term.buffer.active
|
||||||
|
const total = buf.length
|
||||||
|
const rows = term.rows
|
||||||
|
// Default window = the visible screen; baseY is the viewport's top row.
|
||||||
|
const from = Math.max(0, Math.min(start ?? buf.baseY, total))
|
||||||
|
const to = Math.max(from, Math.min(from + Math.max(1, count ?? rows), total))
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
// translateToString(true) right-trims and resolves wide chars, dropping SGR
|
||||||
|
// colors — exactly what the agent wants.
|
||||||
|
for (let i = from; i < to; i += 1) {
|
||||||
|
lines.push(buf.getLine(i)?.translateToString(true) ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
while (lines.length && !lines[lines.length - 1].trim()) {
|
||||||
|
lines.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_lines: total,
|
||||||
|
start: from,
|
||||||
|
end: to,
|
||||||
|
viewport_rows: rows,
|
||||||
|
cursor_row: buf.baseY + buf.cursorY,
|
||||||
|
text: lines.join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
@@ -9,7 +7,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
import { setTerminalTakeover } from '../store'
|
||||||
|
|
||||||
import { addSelectionShortcutLabel } from './selection'
|
import { addSelectionShortcutLabel } from './selection'
|
||||||
import { useTerminalSession } from './use-terminal-session'
|
import { useTerminalSession } from './use-terminal-session'
|
||||||
@@ -21,41 +19,32 @@ interface TerminalTabProps {
|
|||||||
|
|
||||||
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
||||||
cwd,
|
cwd,
|
||||||
onAddSelectionToChat
|
onAddSelectionToChat
|
||||||
})
|
})
|
||||||
|
|
||||||
const takeover = useStore($terminalTakeover)
|
const label = t.rightSidebar.terminalHide
|
||||||
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
|
|
||||||
|
|
||||||
const toggleTakeover = () => {
|
|
||||||
// Pre-select the Terminal tab so the slot is ready to host us on return.
|
|
||||||
if (takeover) {
|
|
||||||
setRightSidebarTab('terminal')
|
|
||||||
}
|
|
||||||
|
|
||||||
setTerminalTakeover(!takeover)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
|
||||||
<Tip label={label}>
|
<Tip label={label}>
|
||||||
<Button
|
<Button
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="ml-auto size-6 rounded-md text-white!"
|
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
|
||||||
onClick={toggleTakeover}
|
onClick={() => setTerminalTakeover(false)}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
<Codicon name="close" size="0.875rem" />
|
||||||
</Button>
|
</Button>
|
||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
<div className="relative min-h-0 flex-1 bg-(--ui-editor-surface-background) p-2">
|
||||||
{status === 'starting' && (
|
{status === 'starting' && (
|
||||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||||
<Loader
|
<Loader
|
||||||
@@ -84,12 +73,13 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Outer div paints the dark inset; inner div is the xterm host so the
|
{/* Outer div paints terminal inset; inner div is the xterm host so the
|
||||||
canvas sizes to the *content* area and p-2 shows as terminal padding.
|
canvas sizes to the content area and p-2 stays as terminal padding.
|
||||||
Forcing screen/viewport bg avoids xterm's default black peeking
|
Screen/viewport inherit the live skin surface so the terminal blends
|
||||||
through the unused pixels below the last full row. */}
|
with the app and follows light/dark; the xterm canvas itself is
|
||||||
|
painted the resolved surface color in use-terminal-session. */}
|
||||||
<div
|
<div
|
||||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
|
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-(--ui-editor-surface-background)! [&_.xterm-viewport]:bg-(--ui-editor-surface-background)!"
|
||||||
ref={hostRef}
|
ref={hostRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useStore } from '@nanostores/react'
|
|||||||
import { atom } from 'nanostores'
|
import { atom } from 'nanostores'
|
||||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { TERMINAL_BG } from './selection'
|
|
||||||
|
|
||||||
import { TerminalTab } from './index'
|
import { TerminalTab } from './index'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,7 +105,9 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
|||||||
visibility: visible ? 'visible' : 'hidden',
|
visibility: visible ? 'visible' : 'hidden',
|
||||||
pointerEvents: visible ? 'auto' : 'none',
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
zIndex: 4,
|
zIndex: 4,
|
||||||
backgroundColor: TERMINAL_BG,
|
// Match the live skin surface so the header strip (transparent) and body
|
||||||
|
// read as one cohesive pane instead of revealing a near-black slab behind.
|
||||||
|
backgroundColor: 'var(--ui-editor-surface-background)',
|
||||||
contain: 'layout size paint'
|
contain: 'layout size paint'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,101 @@
|
|||||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
// Solarized-derived palette, but with bright ANSI 8–15 promoted to real
|
import type { DesktopTerminalPalette } from '@/themes/types'
|
||||||
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
|
|
||||||
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
|
|
||||||
// We always render the dark canvas — the app's light surfaces can't host the
|
|
||||||
// default skin without dropping below readable contrast.
|
|
||||||
export const TERMINAL_BG = '#002b36'
|
|
||||||
|
|
||||||
const THEME: ITheme = {
|
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
|
||||||
background: TERMINAL_BG,
|
// fixed table per theme type, not luminance-derived. Light/dark diverge on
|
||||||
foreground: '#839496',
|
// purpose so each stays legible (e.g. mustard yellow on white).
|
||||||
cursor: '#93a1a1',
|
const DARK_THEME: ITheme = {
|
||||||
cursorAccent: TERMINAL_BG,
|
background: '#1e1e1e',
|
||||||
selectionBackground: '#586e7555',
|
foreground: '#cccccc',
|
||||||
black: '#073642',
|
cursor: '#cccccc',
|
||||||
red: '#dc322f',
|
cursorAccent: '#1e1e1e',
|
||||||
green: '#859900',
|
selectionBackground: '#264f7866',
|
||||||
yellow: '#b58900',
|
black: '#000000',
|
||||||
blue: '#268bd2',
|
red: '#cd3131',
|
||||||
magenta: '#d33682',
|
green: '#0dbc79',
|
||||||
cyan: '#2aa198',
|
yellow: '#e5e510',
|
||||||
white: '#eee8d5',
|
blue: '#2472c8',
|
||||||
brightBlack: '#586e75',
|
magenta: '#bc3fbc',
|
||||||
brightRed: '#f25c54',
|
cyan: '#11a8cd',
|
||||||
brightGreen: '#b3d437',
|
white: '#e5e5e5',
|
||||||
brightYellow: '#f7c948',
|
brightBlack: '#666666',
|
||||||
brightBlue: '#5fb3ff',
|
brightRed: '#f14c4c',
|
||||||
brightMagenta: '#ff6ab4',
|
brightGreen: '#23d18b',
|
||||||
brightCyan: '#5cd9c8',
|
brightYellow: '#f5f543',
|
||||||
brightWhite: '#fdf6e3'
|
brightBlue: '#3b8eea',
|
||||||
|
brightMagenta: '#d670d6',
|
||||||
|
brightCyan: '#29b8db',
|
||||||
|
brightWhite: '#e5e5e5'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const terminalTheme = (): ITheme => THEME
|
const LIGHT_THEME: ITheme = {
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#333333',
|
||||||
|
cursor: '#333333',
|
||||||
|
cursorAccent: '#ffffff',
|
||||||
|
selectionBackground: '#add6ff80',
|
||||||
|
black: '#000000',
|
||||||
|
red: '#cd3131',
|
||||||
|
green: '#00bc00',
|
||||||
|
yellow: '#949800',
|
||||||
|
blue: '#0451a5',
|
||||||
|
magenta: '#bc05bc',
|
||||||
|
cyan: '#0598bc',
|
||||||
|
white: '#555555',
|
||||||
|
brightBlack: '#666666',
|
||||||
|
brightRed: '#cd3131',
|
||||||
|
brightGreen: '#14ce14',
|
||||||
|
brightYellow: '#b5ba00',
|
||||||
|
brightBlue: '#0451a5',
|
||||||
|
brightMagenta: '#bc05bc',
|
||||||
|
brightCyan: '#0598bc',
|
||||||
|
brightWhite: '#a5a5a5'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Palette by painted mode, optionally overlaid with an imported theme's ANSI
|
||||||
|
// palette (Solarized terminal for the Solarized skin, etc.). `palette` only
|
||||||
|
// fills the slots it defines, so a partial import keeps the mode defaults for
|
||||||
|
// the rest. `background` is a fallback only — withSurface swaps in the live skin
|
||||||
|
// surface at runtime (keeping transparency); minimumContrastRatio keeps colors
|
||||||
|
// crisp against it.
|
||||||
|
export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {
|
||||||
|
const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME
|
||||||
|
|
||||||
|
if (!palette) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = { ...base } as Record<string, string>
|
||||||
|
|
||||||
|
for (const [slot, value] of Object.entries(palette)) {
|
||||||
|
if (value) {
|
||||||
|
overlay[slot] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlay as ITheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a
|
||||||
|
// concrete rgb for the WebGL renderer + contrast clamp. Custom props don't
|
||||||
|
// resolve via getComputedStyle, so probe a real background-color. Read AFTER
|
||||||
|
// applyTheme repaints (mount / rAF post-change) or it lags a frame behind.
|
||||||
|
export function resolveSurfaceColor(fallback: string): string {
|
||||||
|
if (typeof document === 'undefined' || !document.body) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = document.createElement('span')
|
||||||
|
probe.style.cssText =
|
||||||
|
'position:absolute;visibility:hidden;pointer-events:none;background-color:var(--ui-editor-surface-background)'
|
||||||
|
document.body.appendChild(probe)
|
||||||
|
const resolved = getComputedStyle(probe).backgroundColor
|
||||||
|
probe.remove()
|
||||||
|
|
||||||
|
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
|
||||||
|
}
|
||||||
|
|
||||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { WebglAddon } from '@xterm/addon-webgl'
|
import { WebglAddon } from '@xterm/addon-webgl'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
|
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||||
|
import {
|
||||||
|
isAddSelectionShortcut,
|
||||||
|
resolveSurfaceColor,
|
||||||
|
terminalSelectionAnchor,
|
||||||
|
terminalSelectionLabel,
|
||||||
|
terminalTheme
|
||||||
|
} from './selection'
|
||||||
|
|
||||||
type TerminalStatus = 'closed' | 'open' | 'starting'
|
type TerminalStatus = 'closed' | 'open' | 'starting'
|
||||||
|
|
||||||
@@ -64,10 +72,29 @@ function stripEscapeSequences(data: string) {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStartupSpacer(data: string) {
|
// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
|
||||||
const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
|
// us apply control codes (e.g. a clear-screen) while discarding boot spacers and
|
||||||
|
// zsh's reverse-video "%" partial-line marker.
|
||||||
|
function keepEscapeSequences(data: string) {
|
||||||
|
let index = 0
|
||||||
|
let out = ''
|
||||||
|
|
||||||
return text === '' || text === '%'
|
while (index < data.length) {
|
||||||
|
if (data.charCodeAt(index) === 0x1b) {
|
||||||
|
const sequence = readEscapeSequence(data, index)
|
||||||
|
|
||||||
|
if (sequence) {
|
||||||
|
out += sequence
|
||||||
|
index += sequence.length
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripInitialPromptGap(data: string) {
|
function stripInitialPromptGap(data: string) {
|
||||||
@@ -95,6 +122,14 @@ interface UseTerminalSessionOptions {
|
|||||||
onAddSelectionToChat: (text: string, label?: string) => void
|
onAddSelectionToChat: (text: string, label?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind the palette to the live skin surface so the terminal blends with the app
|
||||||
|
// (and the contrast clamp has a real background to work against).
|
||||||
|
function withSurface(theme: ReturnType<typeof terminalTheme>) {
|
||||||
|
const surface = resolveSurfaceColor(theme.background ?? '#ffffff')
|
||||||
|
|
||||||
|
return { ...theme, background: surface, cursorAccent: surface }
|
||||||
|
}
|
||||||
|
|
||||||
function transferHasDropCandidates(t: DataTransfer): boolean {
|
function transferHasDropCandidates(t: DataTransfer): boolean {
|
||||||
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
||||||
return true
|
return true
|
||||||
@@ -184,8 +219,21 @@ function quotePathForShell(path: string, shellName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
||||||
|
// Key off renderedMode (the painted surface type), not resolvedMode (the
|
||||||
|
// clicked switch) — a skin can keep a light surface in "dark" mode, and we
|
||||||
|
// must match the surface or the ANSI palette inverts against it. themeName
|
||||||
|
// re-resolves the canvas surface on skin switches (same mode, new tint).
|
||||||
|
const { renderedMode, theme, themeName } = useTheme()
|
||||||
|
// Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),
|
||||||
|
// matched to the painted variant; built-in skins carry none, so the terminal
|
||||||
|
// keeps its VS Code defaults. withSurface still owns the background, so this
|
||||||
|
// never touches transparency.
|
||||||
|
const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal
|
||||||
|
const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])
|
||||||
|
const initialThemeRef = useRef(activeTheme)
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
const termRef = useRef<Terminal | null>(null)
|
const termRef = useRef<Terminal | null>(null)
|
||||||
|
const webglRef = useRef<WebglAddon | null>(null)
|
||||||
const sessionIdRef = useRef<string | null>(null)
|
const sessionIdRef = useRef<string | null>(null)
|
||||||
const shellNameRef = useRef('shell')
|
const shellNameRef = useRef('shell')
|
||||||
const selectionLabelRef = useRef('')
|
const selectionLabelRef = useRef('')
|
||||||
@@ -200,19 +248,26 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
onAddSelectionToChatRef.current = onAddSelectionToChat
|
onAddSelectionToChatRef.current = onAddSelectionToChat
|
||||||
}, [onAddSelectionToChat])
|
}, [onAddSelectionToChat])
|
||||||
|
|
||||||
|
// Live selection at call time. A redraw-heavy TUI (spinners, clocks) outruns
|
||||||
|
// onSelectionChange, so trust xterm directly — fall back to the native
|
||||||
|
// selection — rather than the cached ref / React state.
|
||||||
|
const readSelection = useCallback(
|
||||||
|
() => termRef.current?.getSelection() || window.getSelection()?.toString() || '',
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const addSelectionToChat = useCallback(() => {
|
const addSelectionToChat = useCallback(() => {
|
||||||
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
|
const selectedText = readSelection() || selectionRef.current
|
||||||
|
|
||||||
const label =
|
|
||||||
selectionLabelRef.current ||
|
|
||||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
|
||||||
|
|
||||||
const trimmed = selectedText.trim()
|
const trimmed = selectedText.trim()
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
selectionLabelRef.current ||
|
||||||
|
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||||
|
|
||||||
onAddSelectionToChatRef.current(trimmed, label)
|
onAddSelectionToChatRef.current(trimmed, label)
|
||||||
termRef.current?.clearSelection()
|
termRef.current?.clearSelection()
|
||||||
selectionRef.current = ''
|
selectionRef.current = ''
|
||||||
@@ -220,15 +275,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
setSelection('')
|
setSelection('')
|
||||||
setSelectionStyle(null)
|
setSelectionStyle(null)
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
}, [])
|
}, [readSelection])
|
||||||
|
|
||||||
|
// Always listen — gating on the React selection state misses selections the
|
||||||
|
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
|
||||||
|
// must reach the shell as clear-screen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selection.trim()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!isAddSelectionShortcut(event)) {
|
if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +294,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||||
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||||
}, [addSelectionToChat, selection])
|
}, [addSelectionToChat, readSelection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current
|
const host = hostRef.current
|
||||||
@@ -264,9 +318,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
lineHeight: 1.12,
|
lineHeight: 1.12,
|
||||||
|
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
||||||
|
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
|
||||||
|
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
|
||||||
|
macOptionClickForcesSelection: true,
|
||||||
macOptionIsMeta: true,
|
macOptionIsMeta: true,
|
||||||
|
// VS Code/Cursor's secret sauce: terminal.integrated.minimumContrastRatio
|
||||||
|
// defaults to 4.5 there. xterm defaults to 1 (off), which paints the raw
|
||||||
|
// saturated ANSI palette — vivid green/cyan on white reads as candy.
|
||||||
|
// Clamping to 4.5:1 darkens/lightens foregrounds against the background
|
||||||
|
// at render time, matching the muted ink-like look of their terminal.
|
||||||
|
minimumContrastRatio: 4.5,
|
||||||
scrollback: 1000,
|
scrollback: 1000,
|
||||||
theme: terminalTheme()
|
theme: withSurface(initialThemeRef.current)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fit = new FitAddon()
|
const fit = new FitAddon()
|
||||||
@@ -276,18 +340,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
term.loadAddon(new Unicode11Addon())
|
term.loadAddon(new Unicode11Addon())
|
||||||
term.loadAddon(new WebLinksAddon())
|
term.loadAddon(new WebLinksAddon())
|
||||||
term.unicode.activeVersion = '11'
|
term.unicode.activeVersion = '11'
|
||||||
term.open(host)
|
|
||||||
term.focus()
|
|
||||||
|
|
||||||
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
|
// Let the GUI chat agent read this pane via the `read_terminal` tool: the
|
||||||
// renderer paints SGR via CSS classes that visibly mute against our skins.
|
// gateway's terminal.read.request handler serializes the buffer through this.
|
||||||
try {
|
setActiveTerminalReader(makeTerminalReader(term))
|
||||||
const webgl = new WebglAddon()
|
|
||||||
webgl.onContextLoss(() => webgl.dispose())
|
|
||||||
term.loadAddon(webgl)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragOver = (e: DragEvent) => {
|
const onDragOver = (e: DragEvent) => {
|
||||||
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
||||||
@@ -328,6 +384,75 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
host.removeEventListener('drop', onDrop)
|
host.removeEventListener('drop', onDrop)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A fresh prompt should sit at the top. Every resize SIGWINCHes the shell,
|
||||||
|
// which reprints its prompt and can leave stale blank rows above it. While
|
||||||
|
// the session is pristine (nothing run yet) we ask the shell to clear +
|
||||||
|
// redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves
|
||||||
|
// multi-line prompts (term.clear() would drop all but the cursor row) and we
|
||||||
|
// stop the moment real output exists, so command scrollback is never wiped.
|
||||||
|
let promptPristine = true
|
||||||
|
let gapCleanupTimer = 0
|
||||||
|
|
||||||
|
// While armed, strip leading blank rows so the prompt lands at the very top
|
||||||
|
// (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the
|
||||||
|
// resize cleanup doesn't reintroduce the blank line.
|
||||||
|
let stripLeading = true
|
||||||
|
|
||||||
|
const armedWrite = (data: string) => {
|
||||||
|
if (!stripLeading) {
|
||||||
|
term.write(data)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = stripInitialPromptGap(data)
|
||||||
|
const visible = stripEscapeSequences(next).replace(/[\s%]/g, '')
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
// Spacer / lone clear-screen / zsh `%` marker: apply control codes but
|
||||||
|
// drop the blank text and stay armed so the prompt still lands at top.
|
||||||
|
const controls = keepEscapeSequences(next)
|
||||||
|
|
||||||
|
if (controls) {
|
||||||
|
term.write(controls)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stripLeading = false
|
||||||
|
term.write(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleGapCleanup = () => {
|
||||||
|
if (!promptPristine) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gapCleanupTimer) {
|
||||||
|
window.clearTimeout(gapCleanupTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
gapCleanupTimer = window.setTimeout(() => {
|
||||||
|
gapCleanupTimer = 0
|
||||||
|
const id = sessionIdRef.current
|
||||||
|
|
||||||
|
if (disposed || !id || !promptPristine) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stripLeading = true
|
||||||
|
void terminalApi.write(id, '\f')
|
||||||
|
term.clearSelection()
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup.push(() => {
|
||||||
|
if (gapCleanupTimer) {
|
||||||
|
window.clearTimeout(gapCleanupTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fitAndResize = () => {
|
const fitAndResize = () => {
|
||||||
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||||
return
|
return
|
||||||
@@ -344,6 +469,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
||||||
lastSentSize = { cols: term.cols, rows: term.rows }
|
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||||
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
||||||
|
scheduleGapCleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +506,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
const id = sessionIdRef.current
|
const id = sessionIdRef.current
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
|
// Once the user submits a line, real output may follow — stop the
|
||||||
|
// pristine-prompt gap cleanup so we never clear command scrollback.
|
||||||
|
if (promptPristine && data.includes('\r')) {
|
||||||
|
promptPristine = false
|
||||||
|
}
|
||||||
|
|
||||||
void terminalApi.write(id, data)
|
void terminalApi.write(id, data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -396,87 +528,88 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
|
|
||||||
cleanup.push(() => selectionDisposable.dispose())
|
cleanup.push(() => selectionDisposable.dispose())
|
||||||
|
|
||||||
term.attachCustomKeyEventHandler(event => {
|
const startSession = () =>
|
||||||
if (event.type !== 'keydown') {
|
void terminalApi
|
||||||
return true
|
.start({ cols: term.cols, cwd, rows: term.rows })
|
||||||
}
|
.then(session => {
|
||||||
|
if (disposed) {
|
||||||
|
void terminalApi.dispose(session.id)
|
||||||
|
|
||||||
if (isAddSelectionShortcut(event) && term.hasSelection()) {
|
return
|
||||||
event.preventDefault()
|
}
|
||||||
addSelectionToChat()
|
|
||||||
|
|
||||||
return false
|
sessionIdRef.current = session.id
|
||||||
}
|
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||||
|
shellNameRef.current = session.shell || 'shell'
|
||||||
|
setShellName(session.shell || 'shell')
|
||||||
|
|
||||||
return true
|
const initial = term.hasSelection() ? term.getSelection() : ''
|
||||||
})
|
selectionRef.current = initial
|
||||||
|
selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
|
||||||
|
|
||||||
fitAndResize()
|
setStatus('open')
|
||||||
|
|
||||||
void terminalApi
|
cleanup.push(
|
||||||
.start({ cols: term.cols, cwd, rows: term.rows })
|
terminalApi.onData(session.id, armedWrite),
|
||||||
.then(session => {
|
terminalApi.onExit(session.id, ({ code, signal }) => {
|
||||||
if (disposed) {
|
setStatus('closed')
|
||||||
void terminalApi.dispose(session.id)
|
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
return
|
window.requestAnimationFrame(() => {
|
||||||
}
|
fitAndResize()
|
||||||
|
term.clearSelection() // drop any selection painted over transient boot rows
|
||||||
sessionIdRef.current = session.id
|
term.focus()
|
||||||
lastSentSize = { cols: term.cols, rows: term.rows }
|
|
||||||
shellNameRef.current = session.shell || 'shell'
|
|
||||||
setShellName(session.shell || 'shell')
|
|
||||||
|
|
||||||
if (term.hasSelection()) {
|
|
||||||
const currentSelection = term.getSelection()
|
|
||||||
selectionRef.current = currentSelection
|
|
||||||
selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
|
|
||||||
} else {
|
|
||||||
selectionRef.current = ''
|
|
||||||
selectionLabelRef.current = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('open')
|
|
||||||
let wrotePromptContent = false
|
|
||||||
|
|
||||||
cleanup.push(
|
|
||||||
terminalApi.onData(session.id, data => {
|
|
||||||
if (wrotePromptContent) {
|
|
||||||
term.write(data)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStartupSpacer(data)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = stripInitialPromptGap(data)
|
|
||||||
|
|
||||||
if (next) {
|
|
||||||
wrotePromptContent = true
|
|
||||||
term.write(next)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
terminalApi.onExit(session.id, sessionExit => {
|
|
||||||
const { code, signal } = sessionExit
|
|
||||||
setStatus('closed')
|
|
||||||
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
fitAndResize()
|
|
||||||
term.focus()
|
|
||||||
})
|
})
|
||||||
})
|
.catch(error => {
|
||||||
.catch(error => {
|
setStatus('closed')
|
||||||
setStatus('closed')
|
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
|
||||||
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
|
})
|
||||||
})
|
|
||||||
|
// Open + fit + start only once webfonts settle. Fitting with fallback metrics
|
||||||
|
// picks the wrong row count, the shell boots at that size, then the real font
|
||||||
|
// loads -> refit -> SIGWINCH -> the shell reprints its prompt lower, leaving
|
||||||
|
// stale blank rows (and a stray selection) above it.
|
||||||
|
const mount = () => {
|
||||||
|
if (disposed || !host.isConnected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
term.open(host)
|
||||||
|
term.focus()
|
||||||
|
|
||||||
|
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
|
||||||
|
// renderer paints SGR via CSS classes that visibly mute against our skins.
|
||||||
|
try {
|
||||||
|
const webgl = new WebglAddon()
|
||||||
|
webgl.onContextLoss(() => {
|
||||||
|
webgl.dispose()
|
||||||
|
webglRef.current = null
|
||||||
|
})
|
||||||
|
term.loadAddon(webgl)
|
||||||
|
webglRef.current = webgl
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fitAndResize()
|
||||||
|
startSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
|
||||||
|
|
||||||
|
if (fonts?.ready) {
|
||||||
|
void fonts.ready.then(mount, mount)
|
||||||
|
} else {
|
||||||
|
mount()
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
cleanup.forEach(run => run())
|
cleanup.forEach(run => run())
|
||||||
|
setActiveTerminalReader(null)
|
||||||
|
|
||||||
const id = sessionIdRef.current
|
const id = sessionIdRef.current
|
||||||
sessionIdRef.current = null
|
sessionIdRef.current = null
|
||||||
@@ -487,12 +620,34 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
|
|
||||||
term.dispose()
|
term.dispose()
|
||||||
termRef.current = null
|
termRef.current = null
|
||||||
|
webglRef.current = null
|
||||||
shellNameRef.current = 'shell'
|
shellNameRef.current = 'shell'
|
||||||
selectionRef.current = ''
|
selectionRef.current = ''
|
||||||
selectionLabelRef.current = ''
|
selectionLabelRef.current = ''
|
||||||
}
|
}
|
||||||
}, [addSelectionToChat, cwd])
|
}, [addSelectionToChat, cwd])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = termRef.current
|
||||||
|
|
||||||
|
if (!term) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-resolve the surface in a rAF: ThemeProvider's applyTheme repaints the
|
||||||
|
// CSS vars in a sibling effect that runs after this one, so reading now
|
||||||
|
// would lag a mode behind. By the next frame the vars are current.
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
term.options.theme = withSurface(activeTheme)
|
||||||
|
// The WebGL renderer caches glyph colors in a texture atlas, so a
|
||||||
|
// light/dark switch leaves already-drawn cells stale until the atlas is
|
||||||
|
// cleared. No-op for the DOM fallback.
|
||||||
|
webglRef.current?.clearTextureAtlas()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [activeTheme, themeName])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addSelectionToChat,
|
addSelectionToChat,
|
||||||
hostRef,
|
hostRef,
|
||||||
|
|||||||
32
apps/desktop/src/app/session-picker-overlay.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
|
||||||
|
import { SessionPickerDialog } from '@/components/session-picker'
|
||||||
|
import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session'
|
||||||
|
|
||||||
|
interface SessionPickerOverlayProps {
|
||||||
|
onResume: (storedSessionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens —
|
||||||
|
* the desktop equivalent of the TUI's sessions overlay. Resuming runs through
|
||||||
|
* the same `resumeSession` path the sidebar uses.
|
||||||
|
*/
|
||||||
|
export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) {
|
||||||
|
const open = useStore($sessionPickerOpen)
|
||||||
|
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||||
|
const activeStoredSessionId = useStore($selectedStoredSessionId)
|
||||||
|
|
||||||
|
if (!gatewayOpen) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionPickerDialog
|
||||||
|
activeStoredSessionId={activeStoredSessionId}
|
||||||
|
onOpenChange={setSessionPickerOpen}
|
||||||
|
onResume={onResume}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
apps/desktop/src/app/session-switcher.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
|
||||||
|
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
|
||||||
|
|
||||||
|
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
|
||||||
|
import { sessionRoute } from './routes'
|
||||||
|
|
||||||
|
// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows
|
||||||
|
// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global.
|
||||||
|
export function SessionSwitcher() {
|
||||||
|
const open = useStore($switcherOpen)
|
||||||
|
const sessions = useStore($switcherSessions)
|
||||||
|
const index = useStore($switcherIndex)
|
||||||
|
const working = useStore($workingSessionIds)
|
||||||
|
const attention = useStore($attentionSessionIds)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const activeRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeRef.current?.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [index, open])
|
||||||
|
|
||||||
|
if (!open || sessions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingIds = new Set(working)
|
||||||
|
const attentionIds = new Set(attention)
|
||||||
|
|
||||||
|
const pick = (sessionId: string) => {
|
||||||
|
closeSwitcher()
|
||||||
|
navigate(sessionRoute(sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* Transparent click-catcher: click-away closes, but no dim/blur. */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[219]"
|
||||||
|
onMouseDown={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
closeSwitcher()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
HUD_POSITION,
|
||||||
|
HUD_SURFACE,
|
||||||
|
'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sessions.map((session, i) => {
|
||||||
|
const selected = i === index
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center rounded leading-tight',
|
||||||
|
HUD_ITEM,
|
||||||
|
HUD_TEXT,
|
||||||
|
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
|
||||||
|
)}
|
||||||
|
key={session.id}
|
||||||
|
onMouseDown={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
pick(session.id)
|
||||||
|
}}
|
||||||
|
ref={selected ? activeRef : undefined}
|
||||||
|
>
|
||||||
|
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
|
||||||
|
{i < 9 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 font-mono text-[0.625rem] tabular-nums',
|
||||||
|
selected ? 'text-accent-foreground/70' : 'text-(--ui-text-quaternary)'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
⌃{i + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-1 shrink-0 rounded-full',
|
||||||
|
attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||||
import {
|
import {
|
||||||
appendAssistantTextPart,
|
appendAssistantTextPart,
|
||||||
appendReasoningPart,
|
appendReasoningPart,
|
||||||
@@ -18,6 +19,7 @@ import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
|||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||||
import { setClarifyRequest } from '@/store/clarify'
|
import { setClarifyRequest } from '@/store/clarify'
|
||||||
|
import { $gateway } from '@/store/gateway'
|
||||||
import { notify } from '@/store/notifications'
|
import { notify } from '@/store/notifications'
|
||||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||||
@@ -631,14 +633,21 @@ export function useMessageStream({
|
|||||||
const runningChanged = typeof payload?.running === 'boolean'
|
const runningChanged = typeof payload?.running === 'boolean'
|
||||||
|
|
||||||
if (apply) {
|
if (apply) {
|
||||||
const runtimeInfo: { branch?: string; cwd?: string } = {}
|
const runtimeInfo: Partial<
|
||||||
|
Pick<
|
||||||
|
ClientSessionState,
|
||||||
|
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||||
|
>
|
||||||
|
> = {}
|
||||||
|
|
||||||
if (modelChanged) {
|
if (modelChanged) {
|
||||||
setCurrentModel(payload!.model || '')
|
setCurrentModel(payload!.model || '')
|
||||||
|
runtimeInfo.model = payload!.model || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providerChanged) {
|
if (providerChanged) {
|
||||||
setCurrentProvider(payload!.provider || '')
|
setCurrentProvider(payload!.provider || '')
|
||||||
|
runtimeInfo.provider = payload!.provider || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload?.cwd === 'string') {
|
if (typeof payload?.cwd === 'string') {
|
||||||
@@ -651,32 +660,32 @@ export function useMessageStream({
|
|||||||
runtimeInfo.branch = payload.branch
|
runtimeInfo.branch = payload.branch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
|
|
||||||
updateSessionState(sessionId, state => ({
|
|
||||||
...state,
|
|
||||||
branch: runtimeInfo.branch ?? state.branch,
|
|
||||||
cwd: runtimeInfo.cwd ?? state.cwd
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload?.personality === 'string') {
|
if (typeof payload?.personality === 'string') {
|
||||||
setCurrentPersonality(normalizePersonalityValue(payload.personality))
|
setCurrentPersonality(normalizePersonalityValue(payload.personality))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload?.reasoning_effort === 'string') {
|
if (typeof payload?.reasoning_effort === 'string') {
|
||||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||||
|
runtimeInfo.reasoningEffort = payload.reasoning_effort
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload?.service_tier === 'string') {
|
if (typeof payload?.service_tier === 'string') {
|
||||||
setCurrentServiceTier(payload.service_tier)
|
setCurrentServiceTier(payload.service_tier)
|
||||||
|
runtimeInfo.serviceTier = payload.service_tier
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload?.fast === 'boolean') {
|
if (typeof payload?.fast === 'boolean') {
|
||||||
setCurrentFastMode(payload.fast)
|
setCurrentFastMode(payload.fast)
|
||||||
|
runtimeInfo.fast = payload.fast
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload?.yolo === 'boolean') {
|
if (typeof payload?.yolo === 'boolean') {
|
||||||
setYoloActive(payload.yolo)
|
setYoloActive(payload.yolo)
|
||||||
|
runtimeInfo.yolo = payload.yolo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId && Object.keys(runtimeInfo).length > 0) {
|
||||||
|
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runningChanged && sessionId) {
|
if (runningChanged && sessionId) {
|
||||||
@@ -906,6 +915,21 @@ export function useMessageStream({
|
|||||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'terminal.read.request') {
|
||||||
|
// read_terminal tool: serialize the renderer's xterm buffer and answer
|
||||||
|
// immediately (Python blocks on the respond). Empty text = no live pane.
|
||||||
|
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||||
|
|
||||||
|
if (requestId) {
|
||||||
|
const start = typeof payload?.start === 'number' ? payload.start : undefined
|
||||||
|
const count = typeof payload?.count === 'number' ? payload.count : undefined
|
||||||
|
const result = readActiveTerminal({ start, count })
|
||||||
|
|
||||||
|
void $gateway.get()?.request('terminal.read.respond', {
|
||||||
|
request_id: requestId,
|
||||||
|
text: result ? JSON.stringify(result) : ''
|
||||||
|
})
|
||||||
|
}
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { getGlobalModelInfo } from '@/hermes'
|
||||||
|
import {
|
||||||
|
$activeSessionId,
|
||||||
|
$currentModel,
|
||||||
|
$currentProvider,
|
||||||
|
setCurrentModel,
|
||||||
|
setCurrentProvider
|
||||||
|
} from '@/store/session'
|
||||||
|
|
||||||
|
import { useModelControls } from './use-model-controls'
|
||||||
|
|
||||||
|
vi.mock('@/hermes', () => ({
|
||||||
|
getGlobalModelInfo: vi.fn(),
|
||||||
|
setGlobalModel: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useModelControls.refreshCurrentModel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
$activeSessionId.set(null)
|
||||||
|
setCurrentModel('')
|
||||||
|
setCurrentProvider('')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
$activeSessionId.set(null)
|
||||||
|
setCurrentModel('')
|
||||||
|
setCurrentProvider('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the global model when there is no active runtime session', async () => {
|
||||||
|
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||||
|
model: 'openai/gpt-5.5',
|
||||||
|
provider: 'openai-codex'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useModelControls({
|
||||||
|
activeSessionId: null,
|
||||||
|
queryClient: new QueryClient(),
|
||||||
|
requestGateway: vi.fn()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await result.current.refreshCurrentModel()
|
||||||
|
|
||||||
|
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||||
|
expect($currentProvider.get()).toBe('openai-codex')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not clobber the active session footer state with global model info', async () => {
|
||||||
|
setCurrentModel('deepseek/deepseek-v4-pro')
|
||||||
|
setCurrentProvider('deepseek')
|
||||||
|
$activeSessionId.set('runtime-1')
|
||||||
|
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||||
|
model: 'openai/gpt-5.5',
|
||||||
|
provider: 'openai-codex'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useModelControls({
|
||||||
|
activeSessionId: 'runtime-1',
|
||||||
|
queryClient: new QueryClient(),
|
||||||
|
requestGateway: vi.fn()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await result.current.refreshCurrentModel()
|
||||||
|
|
||||||
|
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
||||||
|
expect($currentProvider.get()).toBe('deepseek')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,13 @@ import { useCallback } from 'react'
|
|||||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
import {
|
||||||
|
$activeSessionId,
|
||||||
|
$currentModel,
|
||||||
|
$currentProvider,
|
||||||
|
setCurrentModel,
|
||||||
|
setCurrentProvider
|
||||||
|
} from '@/store/session'
|
||||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||||
|
|
||||||
interface ModelSelection {
|
interface ModelSelection {
|
||||||
@@ -39,6 +45,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||||||
try {
|
try {
|
||||||
const result = await getGlobalModelInfo()
|
const result = await getGlobalModelInfo()
|
||||||
|
|
||||||
|
// A resumed/live session owns the footer model state. Global config
|
||||||
|
// refreshes (gateway boot, profile swap, settings save) must not clobber
|
||||||
|
// the active chat's runtime model/provider in the status bar.
|
||||||
|
if ($activeSessionId.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof result.model === 'string') {
|
if (typeof result.model === 'string') {
|
||||||
setCurrentModel(result.model)
|
setCurrentModel(result.model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { cleanup, render } from '@testing-library/react'
|
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||||
import type { MutableRefObject } from 'react'
|
import type { MutableRefObject } from 'react'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { $sessions, setSessions } from '@/store/session'
|
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||||
|
import { $connection, $sessions, setSessions } from '@/store/session'
|
||||||
import type { SessionInfo } from '@/types/hermes'
|
import type { SessionInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { usePromptActions } from './use-prompt-actions'
|
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||||
@@ -41,8 +42,12 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HarnessHandle {
|
interface HarnessHandle {
|
||||||
|
cancelRun: () => Promise<void>
|
||||||
steerPrompt: (text: string) => Promise<boolean>
|
steerPrompt: (text: string) => Promise<boolean>
|
||||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
submitText: (
|
||||||
|
text: string,
|
||||||
|
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||||
|
) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Harness({
|
function Harness({
|
||||||
@@ -50,17 +55,29 @@ function Harness({
|
|||||||
onReady,
|
onReady,
|
||||||
onSeedState,
|
onSeedState,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
requestGateway
|
requestGateway,
|
||||||
|
resumeStoredSession,
|
||||||
|
storedSessionId
|
||||||
}: {
|
}: {
|
||||||
busyRef?: MutableRefObject<boolean>
|
busyRef?: MutableRefObject<boolean>
|
||||||
onReady: (handle: HarnessHandle) => void
|
onReady: (handle: HarnessHandle) => void
|
||||||
onSeedState?: (state: Record<string, unknown>) => void
|
onSeedState?: (state: Record<string, unknown>) => void
|
||||||
refreshSessions: () => Promise<void>
|
refreshSessions: () => Promise<void>
|
||||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||||
|
storedSessionId?: null | string
|
||||||
}) {
|
}) {
|
||||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
|
||||||
|
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
|
||||||
|
}
|
||||||
const localBusyRef = busyRef ?? { current: false }
|
const localBusyRef = busyRef ?? { current: false }
|
||||||
|
const stateRef = useRef({
|
||||||
|
messages: [],
|
||||||
|
busy: false,
|
||||||
|
awaitingResponse: false,
|
||||||
|
interrupted: true
|
||||||
|
} as never)
|
||||||
|
|
||||||
const actions = usePromptActions({
|
const actions = usePromptActions({
|
||||||
activeSessionId: RUNTIME_SESSION_ID,
|
activeSessionId: RUNTIME_SESSION_ID,
|
||||||
@@ -71,17 +88,14 @@ function Harness({
|
|||||||
handleSkinCommand: () => '',
|
handleSkinCommand: () => '',
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
|
resumeStoredSession: resumeStoredSession ?? (() => undefined),
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
startFreshSessionDraft: () => undefined,
|
startFreshSessionDraft: () => undefined,
|
||||||
sttEnabled: false,
|
sttEnabled: false,
|
||||||
updateSessionState: (_sessionId, updater) => {
|
updateSessionState: (_sessionId, updater) => {
|
||||||
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
||||||
const next = updater({
|
const next = updater(stateRef.current) as unknown as Record<string, unknown>
|
||||||
messages: [],
|
stateRef.current = next as never
|
||||||
busy: false,
|
|
||||||
awaitingResponse: false,
|
|
||||||
interrupted: true
|
|
||||||
} as never) as unknown as Record<string, unknown>
|
|
||||||
onSeedState?.(next)
|
onSeedState?.(next)
|
||||||
|
|
||||||
return next as never
|
return next as never
|
||||||
@@ -89,8 +103,12 @@ function Harness({
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
|
onReady({
|
||||||
}, [actions.steerPrompt, actions.submitText, onReady])
|
cancelRun: actions.cancelRun,
|
||||||
|
steerPrompt: actions.steerPrompt,
|
||||||
|
submitText: actions.submitText
|
||||||
|
})
|
||||||
|
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -182,6 +200,68 @@ describe('usePromptActions /title', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('usePromptActions desktop slash pickers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
|
||||||
|
const resumeStoredSession = vi.fn(async () => undefined)
|
||||||
|
const requestGateway = vi.fn(async () => ({}) as never)
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
onReady={h => (handle = h)}
|
||||||
|
refreshSessions={async () => undefined}
|
||||||
|
requestGateway={requestGateway}
|
||||||
|
resumeStoredSession={resumeStoredSession}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
await handle!.submitText('/resume 20260610_130000_123abc')
|
||||||
|
|
||||||
|
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
|
||||||
|
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
|
||||||
|
if (method === 'handoff.state') {
|
||||||
|
return { state: 'pending' } as never
|
||||||
|
}
|
||||||
|
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
|
const result = handle!.submitText('/handoff telegram')
|
||||||
|
await vi.advanceTimersByTimeAsync(61_000)
|
||||||
|
await result
|
||||||
|
|
||||||
|
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: 'handoff.fail',
|
||||||
|
params: {
|
||||||
|
error: expect.stringContaining('Timed out'),
|
||||||
|
session_id: RUNTIME_SESSION_ID
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('usePromptActions submit / queue drain semantics', () => {
|
describe('usePromptActions submit / queue drain semantics', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
@@ -314,3 +394,469 @@ describe('usePromptActions steerPrompt', () => {
|
|||||||
expect(requestGateway).not.toHaveBeenCalled()
|
expect(requestGateway).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('usePromptActions file attachment sync', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
$connection.set(null)
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function fileAttachment(): ComposerAttachment {
|
||||||
|
return {
|
||||||
|
id: 'file:report.txt',
|
||||||
|
kind: 'file',
|
||||||
|
label: 'report.txt',
|
||||||
|
path: '/Users/alice/Downloads/report.txt',
|
||||||
|
refText: '@file:`/Users/alice/Downloads/report.txt`'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
|
||||||
|
// Remote gateway can't read the client-disk path, so the desktop must upload
|
||||||
|
// the bytes and submit the workspace-relative ref the gateway hands back —
|
||||||
|
// not the original /Users/... path (which would dead-end as "outside the
|
||||||
|
// allowed workspace").
|
||||||
|
$connection.set({ mode: 'remote' } as never)
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', {
|
||||||
|
configurable: true,
|
||||||
|
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
|
||||||
|
})
|
||||||
|
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
if (method === 'file.attach') {
|
||||||
|
return {
|
||||||
|
attached: true,
|
||||||
|
path: '/remote/work/.hermes/desktop-attachments/report.txt',
|
||||||
|
ref_text: '@file:.hermes/desktop-attachments/report.txt',
|
||||||
|
uploaded: true
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
|
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
|
||||||
|
expect(calls[0]?.params).toMatchObject({
|
||||||
|
session_id: RUNTIME_SESSION_ID,
|
||||||
|
path: '/Users/alice/Downloads/report.txt',
|
||||||
|
name: 'report.txt',
|
||||||
|
data_url: 'data:text/plain;base64,aGVsbG8='
|
||||||
|
})
|
||||||
|
expect(calls[1]?.params).toEqual({
|
||||||
|
session_id: RUNTIME_SESSION_ID,
|
||||||
|
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
|
||||||
|
// Submit-layer contract: only attachments that carry a `path` are upload
|
||||||
|
// candidates. A path-less ref (an @-mention/context ref or pasted text)
|
||||||
|
// has no bytes to send, so syncAttachments leaves it untouched and the ref
|
||||||
|
// reaches the gateway as-is — correct for workspace-relative refs.
|
||||||
|
//
|
||||||
|
// The MahmoudR drag-drop bug (a Finder PDF that became a local-path text
|
||||||
|
// ref in remote mode) is fixed upstream at the DROP layer: OS drops now
|
||||||
|
// carry a path and route through the upload pipeline instead of becoming a
|
||||||
|
// path-less inline ref. See partitionDroppedFiles in use-composer-actions.
|
||||||
|
$connection.set({ mode: 'remote' } as never)
|
||||||
|
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', {
|
||||||
|
configurable: true,
|
||||||
|
value: { readFileDataUrl }
|
||||||
|
})
|
||||||
|
|
||||||
|
const pathlessRef: ComposerAttachment = {
|
||||||
|
id: 'file:devis',
|
||||||
|
kind: 'file',
|
||||||
|
label: 'DEVIS_signed.pdf',
|
||||||
|
// NOTE: no `path` field — only the pre-baked local @file: ref.
|
||||||
|
refText: '@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`'
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
|
const ok = await handle!.submitText('read this file', { attachments: [pathlessRef] })
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
// No path → no file.attach, no byte read: the ref passes through unchanged.
|
||||||
|
expect(calls.map(c => c.method)).toEqual(['prompt.submit'])
|
||||||
|
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||||
|
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
|
||||||
|
$connection.set({ mode: 'local' } as never)
|
||||||
|
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
if (method === 'file.attach') {
|
||||||
|
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
|
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(calls[0]?.method).toBe('file.attach')
|
||||||
|
// Local mode sends no data_url — the gateway shares this disk.
|
||||||
|
expect(calls[0]?.params).not.toHaveProperty('data_url')
|
||||||
|
expect(calls[1]).toEqual({
|
||||||
|
method: 'prompt.submit',
|
||||||
|
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('usePromptActions eager-upload races', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setSessions(() => [sessionInfo()])
|
||||||
|
$composerAttachments.set([])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
$composerAttachments.set([])
|
||||||
|
$connection.set(null)
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins an in-flight eager upload at submit instead of staging the file twice', async () => {
|
||||||
|
// Drop-then-immediately-Enter: the drop kicks off an eager file.attach; if
|
||||||
|
// submit doesn't join it, both calls stage the file and leave a duplicate
|
||||||
|
// under .hermes/desktop-attachments/. Submit must await the in-flight upload
|
||||||
|
// and reuse its gateway-side ref.
|
||||||
|
$connection.set({ mode: 'remote' } as never)
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', {
|
||||||
|
configurable: true,
|
||||||
|
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
|
||||||
|
})
|
||||||
|
|
||||||
|
let releaseAttach: () => void = () => {}
|
||||||
|
const methods: string[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
methods.push(method)
|
||||||
|
if (method === 'file.attach') {
|
||||||
|
// Block until released so submit runs while the upload is in flight.
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
releaseAttach = resolve
|
||||||
|
})
|
||||||
|
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
await waitFor(() => expect(handle).not.toBeNull())
|
||||||
|
|
||||||
|
// Drop a file → the eager effect fires file.attach and blocks on it.
|
||||||
|
$composerAttachments.set([{ id: 'file:doc.pdf', kind: 'file', label: 'doc.pdf', path: '/Users/me/doc.pdf' }])
|
||||||
|
await waitFor(() => expect(methods.filter(m => m === 'file.attach').length).toBe(1))
|
||||||
|
|
||||||
|
// Submit reads the store, sees the upload in flight, and joins it.
|
||||||
|
const submitting = handle!.submitText('here you go')
|
||||||
|
releaseAttach()
|
||||||
|
|
||||||
|
expect(await submitting).toBe(true)
|
||||||
|
// Exactly one file.attach (submit reused the eager result), then the send.
|
||||||
|
expect(methods.filter(m => m === 'file.attach').length).toBe(1)
|
||||||
|
expect(methods).toContain('prompt.submit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('usePromptActions sleep/wake session recovery', () => {
|
||||||
|
const STORED_SESSION_ID = 'stored-db-xyz789'
|
||||||
|
const RECOVERED_SESSION_ID = 'rt-recovered-456'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => {
|
||||||
|
// After sleep/wake the gateway's in-memory session table is cleared, so the
|
||||||
|
// first prompt.submit with the stale runtime id fails. The hook resumes the
|
||||||
|
// durable stored id (which survives gateway restarts), gets a fresh live id,
|
||||||
|
// and retries the send transparently.
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
let submitAttempts = 0
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
if (method === 'prompt.submit') {
|
||||||
|
submitAttempts += 1
|
||||||
|
if (submitAttempts === 1) {
|
||||||
|
throw new Error('session not found')
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
}
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
return { session_id: RECOVERED_SESSION_ID } as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
onReady={h => (handle = h)}
|
||||||
|
refreshSessions={async () => undefined}
|
||||||
|
requestGateway={requestGateway}
|
||||||
|
storedSessionId={STORED_SESSION_ID}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = await handle!.submitText('message after wake')
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
|
||||||
|
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
|
||||||
|
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||||
|
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
|
||||||
|
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||||
|
let interruptAttempts = 0
|
||||||
|
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||||
|
calls.push({ method, params })
|
||||||
|
if (method === 'session.interrupt') {
|
||||||
|
interruptAttempts += 1
|
||||||
|
if (interruptAttempts === 1) {
|
||||||
|
throw new Error('session not found')
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
}
|
||||||
|
if (method === 'session.resume') {
|
||||||
|
return { session_id: RECOVERED_SESSION_ID } as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
onReady={h => (handle = h)}
|
||||||
|
refreshSessions={async () => undefined}
|
||||||
|
requestGateway={requestGateway}
|
||||||
|
storedSessionId={STORED_SESSION_ID}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(handle).not.toBeNull())
|
||||||
|
|
||||||
|
await handle!.cancelRun()
|
||||||
|
|
||||||
|
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
|
||||||
|
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
|
||||||
|
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||||
|
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const states: Record<string, unknown>[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
calls.push(method)
|
||||||
|
if (method === 'prompt.submit') {
|
||||||
|
throw new Error('session busy')
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
onReady={h => (handle = h)}
|
||||||
|
onSeedState={s => states.push(s)}
|
||||||
|
refreshSessions={async () => undefined}
|
||||||
|
requestGateway={requestGateway}
|
||||||
|
storedSessionId={STORED_SESSION_ID}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// submitText swallows the error into an inline bubble and returns false.
|
||||||
|
expect(await handle!.submitText('message')).toBe(false)
|
||||||
|
// No resume attempt for a non-recoverable error.
|
||||||
|
expect(calls).not.toContain('session.resume')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
calls.push(method)
|
||||||
|
if (method === 'prompt.submit') {
|
||||||
|
throw new Error('session not found')
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
onReady={h => (handle = h)}
|
||||||
|
refreshSessions={async () => undefined}
|
||||||
|
requestGateway={requestGateway}
|
||||||
|
storedSessionId={null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard
|
||||||
|
// short-circuits — no resume is attempted and the error surfaces normally.
|
||||||
|
expect(await handle!.submitText('message')).toBe(false)
|
||||||
|
expect(calls).not.toContain('session.resume')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('usePromptActions eager attachment upload (drop-time)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
$connection.set(null)
|
||||||
|
$composerAttachments.set([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads a dropped file the moment it lands (active session) and rewrites the chip with the gateway ref', async () => {
|
||||||
|
// A Finder drop adds a chip with a local path but no attachedSessionId. With
|
||||||
|
// a session already open, the hook should stage it right away — so the send
|
||||||
|
// is instant and the card can show a spinner while bytes upload — instead of
|
||||||
|
// waiting for submit.
|
||||||
|
$connection.set({ mode: 'remote' } as never)
|
||||||
|
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { readFileDataUrl } })
|
||||||
|
|
||||||
|
const calls: string[] = []
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
calls.push(method)
|
||||||
|
if (method === 'file.attach') {
|
||||||
|
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf', uploaded: true } as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
$composerAttachments.set([
|
||||||
|
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
|
||||||
|
])
|
||||||
|
|
||||||
|
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(calls).toContain('file.attach'))
|
||||||
|
await waitFor(() => expect($composerAttachments.get()[0]?.attachedSessionId).toBe(RUNTIME_SESSION_ID))
|
||||||
|
|
||||||
|
const chip = $composerAttachments.get()[0]!
|
||||||
|
expect(chip.refText).toBe('@file:.hermes/desktop-attachments/DEVIS_signed.pdf')
|
||||||
|
expect(chip.uploadState).toBeUndefined()
|
||||||
|
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags the chip uploadState=error when the eager upload fails, keeping the path so submit can retry', async () => {
|
||||||
|
$connection.set({ mode: 'remote' } as never)
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', {
|
||||||
|
configurable: true,
|
||||||
|
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestGateway = vi.fn(async (method: string) => {
|
||||||
|
if (method === 'file.attach') {
|
||||||
|
throw new Error('[Errno 13] Permission denied')
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
|
||||||
|
|
||||||
|
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
|
await waitFor(() => expect($composerAttachments.get()[0]?.uploadState).toBe('error'))
|
||||||
|
expect($composerAttachments.get()[0]?.attachedSessionId).toBeUndefined()
|
||||||
|
expect($composerAttachments.get()[0]?.path).toBe('/abs/x.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not eagerly re-upload a chip already attached to this session', async () => {
|
||||||
|
$connection.set({ mode: 'remote' } as never)
|
||||||
|
const requestGateway = vi.fn(async () => ({}) as never)
|
||||||
|
|
||||||
|
$composerAttachments.set([
|
||||||
|
{
|
||||||
|
id: 'file:done',
|
||||||
|
kind: 'file',
|
||||||
|
label: 'done.pdf',
|
||||||
|
path: '/abs/done.pdf',
|
||||||
|
refText: '@file:data/done.pdf',
|
||||||
|
attachedSessionId: RUNTIME_SESSION_ID
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(requestGateway).not.toHaveBeenCalledWith('file.attach', expect.anything())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('uploadComposerAttachment remote read failures', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
|
||||||
|
// electron/hardening.cjs rejects the readFileDataUrl IPC with this exact
|
||||||
|
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
readFileDataUrl: vi.fn(async () => {
|
||||||
|
throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestGateway = vi.fn(async () => ({}) as never)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadComposerAttachment(
|
||||||
|
{ id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' },
|
||||||
|
{ remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID }
|
||||||
|
)
|
||||||
|
).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).')
|
||||||
|
|
||||||
|
// The cap is hit before any gateway round-trip.
|
||||||
|
expect(requestGateway).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes non-cap read errors through unchanged', async () => {
|
||||||
|
Object.defineProperty(window, 'hermesDesktop', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
readFileDataUrl: vi.fn(async () => {
|
||||||
|
throw new Error('ENOENT: no such file')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadComposerAttachment(
|
||||||
|
{ id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' },
|
||||||
|
{ remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID }
|
||||||
|
)
|
||||||
|
).rejects.toThrow('ENOENT: no such file')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||