Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3723bf5fe6 | ||
|
|
a348fc1ccc | ||
|
|
677680034a | ||
|
|
222126db1d | ||
|
|
418ceaf8c1 | ||
|
|
c7e23690e0 | ||
|
|
4d8bfa103b | ||
|
|
a68ac0c49a | ||
|
|
9d05f3721d | ||
|
|
16fc717091 | ||
|
|
925b0d1ab5 | ||
|
|
e65d74bc6f | ||
|
|
4858942c55 | ||
|
|
23cc009879 | ||
|
|
946d3eaf95 | ||
|
|
bf45aa3a45 | ||
|
|
16e408f3f0 | ||
|
|
4108fe6014 | ||
|
|
b0fb2b8b05 | ||
|
|
1ddf7a1021 | ||
|
|
a70f7f3b7b | ||
|
|
6e3c393ef9 | ||
|
|
c7e5215b50 | ||
|
|
25686feebf | ||
|
|
8e3b320eb8 | ||
|
|
f5823277dc | ||
|
|
33924c074c | ||
|
|
21c64f90aa | ||
|
|
8e853e3ff8 | ||
|
|
76eab10b14 | ||
|
|
5c5a1fec4b | ||
|
|
7016fa4902 | ||
|
|
74cb03423e | ||
|
|
5988e21ed7 | ||
|
|
965226fd52 | ||
|
|
353a8c1c8f | ||
|
|
5747d9a2d8 | ||
|
|
e01b04de46 | ||
|
|
ef9232a2f7 | ||
|
|
5268027e6b | ||
|
|
b6598017c8 | ||
|
|
5af3a81490 | ||
|
|
7b7ab279f2 | ||
|
|
01669f2f12 | ||
|
|
338b5275be | ||
|
|
ef94562125 | ||
|
|
3616b813ec | ||
|
|
e1067dbbe5 | ||
|
|
ab37440ce6 | ||
|
|
cf3002664b | ||
|
|
fc8d5f203a | ||
|
|
c5806b9ad9 | ||
|
|
b145607029 | ||
|
|
4a3b755162 | ||
|
|
16dbcbe85d | ||
|
|
c04aaecb51 | ||
|
|
eaa069e322 | ||
|
|
2d7616121b | ||
|
|
8afb7bc570 | ||
|
|
94765e48ff | ||
|
|
31916539af | ||
|
|
79d1b58afe | ||
|
|
b091b4eaeb | ||
|
|
7e01a96e53 | ||
|
|
31e0adc681 | ||
|
|
8445995321 | ||
|
|
abba43eb63 | ||
|
|
4a0991c1d2 | ||
|
|
364b93a4b9 | ||
|
|
85546bb9e2 | ||
|
|
ba3fe7027c | ||
|
|
5999cd2848 | ||
|
|
639a9cb9a7 | ||
|
|
a09fa9df42 | ||
|
|
4e69fdb3be | ||
|
|
b3efafcc73 | ||
|
|
036e863e4a | ||
|
|
e3cdedbf0f | ||
|
|
38eb9bb19a | ||
|
|
0bb58b65ec | ||
|
|
fb30ff218d | ||
|
|
e1edbb0e89 | ||
|
|
72118b049f | ||
|
|
c007d08419 | ||
|
|
73b261b94f | ||
|
|
f4bb617f62 | ||
|
|
4c630d3e7b | ||
|
|
bc71c57ba9 | ||
|
|
ab5d422835 | ||
|
|
72ee55ed53 | ||
|
|
df4bdc9d58 | ||
|
|
eaad47a6f6 | ||
|
|
8c3060342f | ||
|
|
c9c6cfc0ee | ||
|
|
6438acec60 | ||
|
|
76a8bba15f | ||
|
|
ad220b9d93 | ||
|
|
ca791f4000 | ||
|
|
0dafcdd9e3 | ||
|
|
6e62489d9e | ||
|
|
076aebc7e6 | ||
|
|
b6dc49200d | ||
|
|
0a5b0780f5 | ||
|
|
c4348480f3 | ||
|
|
f76df0688c | ||
|
|
84cbf5c1f3 | ||
|
|
0f92a3cf63 | ||
|
|
60cbc4c68b | ||
|
|
ae11a636dc | ||
|
|
25567919ea | ||
|
|
dcd8ba2a0d | ||
|
|
f205dc2a3b | ||
|
|
c40d3172ac | ||
|
|
52533bea09 | ||
|
|
9eb36fd697 | ||
|
|
f8f4b3044a | ||
|
|
0dc257d610 | ||
|
|
20865a2653 | ||
|
|
da07e67efd | ||
|
|
b36001940a | ||
|
|
f4d944c49c | ||
|
|
e4652b99e2 | ||
|
|
6a73b09d15 | ||
|
|
af82979d43 | ||
|
|
3d87abcf1c | ||
|
|
4cb9aa6664 | ||
|
|
bc79644f16 | ||
|
|
e36b2d1519 | ||
|
|
fe15a9bb00 | ||
|
|
af577a4c5a | ||
|
|
9e81be7228 | ||
|
|
28a2f95631 | ||
|
|
07fcb3282c | ||
|
|
41a5bbf3e8 | ||
|
|
84b77f68e5 | ||
|
|
c29402d731 | ||
|
|
76e9271dce | ||
|
|
60c5a82c85 | ||
|
|
eb4821127c | ||
|
|
028bd89959 | ||
|
|
0f53d67ee4 | ||
|
|
aa5489e804 | ||
|
|
2a86f039ea | ||
|
|
79c6896153 | ||
|
|
46293f618c | ||
|
|
080440bd9c | ||
|
|
bd3c253420 | ||
|
|
82e13ed949 | ||
|
|
fb04e85a14 | ||
|
|
06762a0f5e | ||
|
|
92f35fab19 | ||
|
|
cd11ed7a04 | ||
|
|
4407fee49f | ||
|
|
48d0c70f61 | ||
|
|
4061e635d3 | ||
|
|
741a4c23ca | ||
|
|
c6e72a8454 | ||
|
|
180fe665cb | ||
|
|
6a24249e7c | ||
|
|
ee211d087b | ||
|
|
aec752faa3 | ||
|
|
c9540570ae | ||
|
|
6e3915fbc1 | ||
|
|
c3d2d87a74 | ||
|
|
f423aebb80 | ||
|
|
37b74f4df3 | ||
|
|
247604cdde | ||
|
|
2bb61a7d09 | ||
|
|
1ecec7a9bc | ||
|
|
325350d192 | ||
|
|
1be5bd92fa | ||
|
|
93793b6af5 | ||
|
|
26f6929eb4 | ||
|
|
808ef152e5 | ||
|
|
e7d7e0157f | ||
|
|
edc4164704 | ||
|
|
7412cd5c78 | ||
|
|
3f54152191 | ||
|
|
3fe7709b86 | ||
|
|
abce50e34d | ||
|
|
c704d384f4 | ||
|
|
4f2bb7e52f | ||
|
|
1bc376921f | ||
|
|
6cefb7c5b5 | ||
|
|
59fbc05031 | ||
|
|
4b81ded58b | ||
|
|
927c902785 | ||
|
|
d3943fe37d | ||
|
|
2bd9c9b881 |
+21
-11
@@ -1,12 +1,14 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
# upstream node:22 image instead so we can stay on a supported LTS without
|
||||
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
|
||||
# so the produced binary links against glibc 2.36, which runs cleanly on
|
||||
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
|
||||
# is a one-line ARG change; see #4977.
|
||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# (EOL April 2026), so we copy node + npm + corepack from the upstream node:26
|
||||
# image instead. Node 26 (Current; LTS promotion ~Oct 2026) is REQUIRED by the
|
||||
# native OpenTUI TUI engine, which loads its renderer via the experimental
|
||||
# `node:ffi` API that only exists on Node 26.3+ (the Ink engine + web build run
|
||||
# on it too). Bookworm-based slim image used so the produced binary links
|
||||
# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc 2.41)
|
||||
# runtime. The pinned tag ships v26.3.0. Bumping Node is a one-line change here.
|
||||
# NOTE: verify the full image build + Ink/web/Playwright on Node 26 in CI.
|
||||
FROM node:26-bookworm-slim@sha256:79723b41edbedf595f62e943a9f8b0ba9af5b1e61045c5f8f59c2c02c1212a16 AS node_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
@@ -90,7 +92,7 @@ RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
||||
|
||||
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
|
||||
# Node 26: copy the node binary plus the bundled npm + corepack JS
|
||||
# installs from the upstream image. npm and npx are recreated as symlinks
|
||||
# because they're symlinks in the source image (and need to live on PATH).
|
||||
# See node_source stage at the top of the file for the version-bump
|
||||
@@ -119,7 +121,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
# what the image ships now (via the node:22 source stage). We set it
|
||||
# what the image ships now (via the node:26 source stage). We set it
|
||||
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
||||
# 9.x defaulted to install-as-copy, which produced a hidden
|
||||
# node_modules/.package-lock.json that permanently disagreed with the root
|
||||
@@ -181,8 +183,16 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
||||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
COPY ui-opentui/ ui-opentui/
|
||||
# ui-opentui is the opt-in native OpenTUI engine (HERMES_TUI_ENGINE=opentui;
|
||||
# default stays Ink). .dockerignore strips its node_modules/dist, so install +
|
||||
# esbuild-build it here -> dist/main.js, then prune devDeps (esbuild/babel/
|
||||
# vitest); the runtime only needs the prod deps (the external @opentui/core +
|
||||
# its native blob -- the bundle inlines solid/effect). Build needs Node 26.3
|
||||
# (node:ffi floor), which this image ships.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
cd ../ui-tui && npm run build && \
|
||||
cd ../ui-opentui && npm install --no-audit --no-fund && npm run build && npm prune --omit=dev
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
|
||||
@@ -107,6 +107,8 @@ You can still bring your own keys per-tool whenever you want — the gateway is
|
||||
|
||||
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
|
||||
|
||||
> **TUI engine:** On supported hosts (Linux/macOS with Node 26.3+), the terminal UI defaults to the native **OpenTUI** engine, which the installer provisions for you. The legacy **Ink** engine remains the fallback — it's used automatically on Windows, Termux, or when the native engine can't run, and you can select it explicitly with `HERMES_TUI_ENGINE=ink hermes`. Ink is not going away; it's the kept fallback.
|
||||
|
||||
| Action | CLI | Messaging platforms |
|
||||
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
|
||||
+159
-27
@@ -3079,23 +3079,20 @@ def _try_configured_fallback_chain(
|
||||
if not fb_provider or fb_provider.lower() == skip:
|
||||
continue
|
||||
fb_model = str(entry.get("model", "")).strip() or None
|
||||
fb_base_url = str(entry.get("base_url", "")).strip() or None
|
||||
fb_api_key = str(entry.get("api_key", "")).strip() or None
|
||||
|
||||
label = f"fallback_chain[{i}]({fb_provider})"
|
||||
|
||||
try:
|
||||
fb_client = _resolve_single_provider(
|
||||
fb_provider, fb_model, fb_base_url, fb_api_key)
|
||||
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||
except Exception:
|
||||
fb_client = None
|
||||
fb_client, resolved_model = None, None
|
||||
|
||||
if fb_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
||||
task, reason, failed_provider, label, fb_model or "default",
|
||||
task, reason, failed_provider, label, resolved_model or fb_model or "default",
|
||||
)
|
||||
return fb_client, fb_model, label
|
||||
return fb_client, resolved_model or fb_model, label
|
||||
tried.append(label)
|
||||
|
||||
if tried:
|
||||
@@ -3106,6 +3103,103 @@ def _try_configured_fallback_chain(
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Resolve inline or env-backed API key from a fallback-chain entry."""
|
||||
explicit = str(entry.get("api_key") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
|
||||
if key_env:
|
||||
return os.getenv(key_env, "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Resolve one fallback entry through the central provider router."""
|
||||
provider = str(entry.get("provider") or "").strip()
|
||||
model = str(entry.get("model") or "").strip() or None
|
||||
if not provider or not model:
|
||||
return None, None
|
||||
base_url = str(entry.get("base_url") or "").strip() or None
|
||||
api_key = _fallback_entry_api_key(entry)
|
||||
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
|
||||
return resolve_provider_client(
|
||||
provider,
|
||||
model=model,
|
||||
explicit_base_url=base_url,
|
||||
explicit_api_key=api_key,
|
||||
api_mode=api_mode,
|
||||
)
|
||||
|
||||
|
||||
def _try_main_fallback_chain(
|
||||
task: Optional[str],
|
||||
failed_provider: str = "",
|
||||
reason: str = "error",
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try the top-level main-agent fallback chain for an auxiliary call.
|
||||
|
||||
``provider: auto`` auxiliary tasks should respect the user's declared
|
||||
main fallback policy before dropping into Hermes' built-in discovery
|
||||
chain. The top-level chain is read through ``get_fallback_chain`` so
|
||||
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
|
||||
participate in the same order as the main agent.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
chain = get_fallback_chain(load_config())
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
|
||||
return None, None, ""
|
||||
|
||||
if not chain:
|
||||
return None, None, ""
|
||||
|
||||
failed_norm = (failed_provider or "").strip().lower()
|
||||
main_norm = (_read_main_provider() or "").strip().lower()
|
||||
skip = {p for p in (failed_norm, main_norm, "auto") if p}
|
||||
tried: List[str] = []
|
||||
|
||||
for i, entry in enumerate(chain):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fb_provider = str(entry.get("provider") or "").strip()
|
||||
fb_model = str(entry.get("model") or "").strip()
|
||||
if not fb_provider or not fb_model:
|
||||
continue
|
||||
fb_norm = fb_provider.lower()
|
||||
label = f"fallback_providers[{i}]({fb_provider})"
|
||||
if fb_norm in skip:
|
||||
tried.append(f"{label} (skipped)")
|
||||
continue
|
||||
if _is_provider_unhealthy(fb_norm):
|
||||
_log_skip_unhealthy(fb_norm, task)
|
||||
tried.append(f"{label} (unhealthy)")
|
||||
continue
|
||||
try:
|
||||
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
|
||||
fb_client, resolved_model = None, None
|
||||
if fb_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
|
||||
task or "call", reason, failed_provider or "auto", label,
|
||||
resolved_model or fb_model,
|
||||
)
|
||||
return fb_client, resolved_model or fb_model, fb_provider
|
||||
tried.append(label)
|
||||
|
||||
if tried:
|
||||
logger.debug(
|
||||
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
|
||||
task or "call", ", ".join(tried),
|
||||
)
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _resolve_single_provider(
|
||||
provider: str,
|
||||
model: Optional[str] = None,
|
||||
@@ -3116,16 +3210,19 @@ def _resolve_single_provider(
|
||||
|
||||
Uses the existing provider resolution infrastructure where possible.
|
||||
"""
|
||||
# Reuse resolve_provider_client which handles provider→client mapping
|
||||
# Reuse resolve_provider_client which handles provider→client mapping.
|
||||
client, resolved_model = resolve_provider_client(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
explicit_base_url=base_url,
|
||||
explicit_api_key=api_key,
|
||||
)
|
||||
return client
|
||||
|
||||
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
def _resolve_auto(
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain.
|
||||
|
||||
Priority:
|
||||
@@ -3223,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
main_provider, resolved or main_model)
|
||||
return client, resolved or main_model
|
||||
|
||||
# ── Step 2: aggregator / fallback chain ──────────────────────────────
|
||||
# ── Step 2: user-configured fallback policy ─────────────────────────
|
||||
# In auto mode, respect the task-specific fallback chain first, then the
|
||||
# main agent's top-level fallback_providers/fallback_model chain. The
|
||||
# hardcoded provider discovery chain below is only the convenience default
|
||||
# for users who have not declared a fallback policy.
|
||||
if task:
|
||||
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
|
||||
task, main_provider or "auto", reason="main provider unavailable")
|
||||
if fb_client is not None:
|
||||
return fb_client, fb_model
|
||||
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
|
||||
task, main_provider or "auto", reason="main provider unavailable")
|
||||
if fb_client is not None:
|
||||
return fb_client, fb_model
|
||||
|
||||
# ── Step 3: aggregator / fallback chain ──────────────────────────────
|
||||
tried = []
|
||||
for label, try_fn in _get_provider_chain():
|
||||
if _is_provider_unhealthy(label):
|
||||
@@ -3344,6 +3456,7 @@ def resolve_provider_client(
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Central router: given a provider name and optional model, return a
|
||||
configured client with the correct auth, base URL, and API format.
|
||||
@@ -3464,7 +3577,7 @@ def resolve_provider_client(
|
||||
|
||||
# ── Auto: try all providers in priority order ────────────────────
|
||||
if provider == "auto":
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime)
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
return None, None
|
||||
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
||||
@@ -4357,11 +4470,16 @@ def _client_cache_key(
|
||||
api_mode: Optional[str] = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> tuple:
|
||||
runtime = _normalize_main_runtime(main_runtime)
|
||||
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
|
||||
# `auto` can now resolve through task-specific or main fallback policy,
|
||||
# so the task participates in the cache key. Non-auto providers keep the
|
||||
# old cache shape because the explicit provider/model tuple is sufficient.
|
||||
task_key = (task or "") if provider == "auto" else ""
|
||||
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
|
||||
|
||||
|
||||
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
||||
@@ -4554,6 +4672,7 @@ def _get_cached_client(
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Get or create a cached client for the given provider.
|
||||
|
||||
@@ -4591,6 +4710,7 @@ def _get_cached_client(
|
||||
api_mode=api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=is_vision,
|
||||
task=task,
|
||||
)
|
||||
with _client_cache_lock:
|
||||
if cache_key in _client_cache:
|
||||
@@ -4635,6 +4755,7 @@ def _get_cached_client(
|
||||
api_mode=api_mode,
|
||||
main_runtime=runtime,
|
||||
is_vision=is_vision,
|
||||
task=task,
|
||||
)
|
||||
if client is not None:
|
||||
# For async clients, remember which loop they were created on so we
|
||||
@@ -5140,7 +5261,7 @@ def call_llm(
|
||||
if not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -5466,14 +5587,19 @@ def call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. Main agent model (last-resort safety net)
|
||||
# For auto users (no explicit aux provider), use the full
|
||||
# auto-detection chain instead — its Step 1 IS the main agent
|
||||
# model, so users on `auto` already get main-model fallback.
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
# 3. For auto: built-in auxiliary discovery chain
|
||||
# 4. For explicit aux providers: main agent model safety net
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
@@ -5636,7 +5762,7 @@ async def async_call_llm(
|
||||
if not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -5904,13 +6030,19 @@ async def async_call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. Main agent model (last-resort safety net)
|
||||
# Auto users get the full auto-detection chain instead — its
|
||||
# Step 1 IS the main agent model.
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
# 3. For auto: built-in auxiliary discovery chain
|
||||
# 4. For explicit aux providers: main agent model safety net
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
||||
@@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
BrowserManageResponse,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
@@ -1141,6 +1142,81 @@ export function usePromptActions({
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
// /browser connect|disconnect|status manages the live CDP connection on
|
||||
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
|
||||
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
|
||||
// meaningful when that process runs on this machine, so it's gated to
|
||||
// local connections. A remote gateway would act on the wrong host.
|
||||
browser: async ctx => {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
renderSlashOutput(
|
||||
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
|
||||
const cmdAction = rawAction.toLowerCase()
|
||||
|
||||
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
|
||||
renderSlashOutput(
|
||||
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
|
||||
|
||||
if (url) {
|
||||
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
|
||||
action: cmdAction,
|
||||
session_id: sessionId,
|
||||
...(url && { url })
|
||||
})
|
||||
|
||||
// Without a streamed session subscription, the gateway bundles its
|
||||
// progress lines into `messages` — flush them inline.
|
||||
result?.messages?.forEach(message => renderSlashOutput(message))
|
||||
|
||||
if (cmdAction === 'status') {
|
||||
renderSlashOutput(
|
||||
result?.connected
|
||||
? `browser connected: ${result.url || '(url unavailable)'}`
|
||||
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (cmdAction === 'disconnect') {
|
||||
renderSlashOutput('browser disconnected')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result?.connected) {
|
||||
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
|
||||
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
|
||||
renderSlashOutput('next browser tool call will use this CDP endpoint')
|
||||
}
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface SlashExecResponse {
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface BrowserManageResponse {
|
||||
connected?: boolean
|
||||
url?: string
|
||||
messages?: string[]
|
||||
}
|
||||
|
||||
export interface SessionSteerResponse {
|
||||
// 'queued' == accepted into the live turn's steer slot (injected at the next
|
||||
// tool-result boundary); 'rejected' == no live tool window, caller queues.
|
||||
|
||||
@@ -52,6 +52,17 @@ describe('desktop slash command curation', () => {
|
||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats /browser as an executable action command (local-gateway connect)', () => {
|
||||
// /browser used to be terminal-only; it now resolves to a desktop action
|
||||
// handler that routes browser.manage RPC when the gateway is local.
|
||||
expect(isDesktopSlashCommand('/browser')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
|
||||
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
|
||||
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
|
||||
// Bare /browser expands to its sub-action options in the popover.
|
||||
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
|
||||
})
|
||||
|
||||
it('allows aliases to execute without cluttering the popover', () => {
|
||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption {
|
||||
*/
|
||||
export type DesktopActionId =
|
||||
| 'branch'
|
||||
| 'browser'
|
||||
| 'handoff'
|
||||
| 'help'
|
||||
| 'new'
|
||||
@@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
|
||||
{ name: '/title', description: 'Rename the current session', surface: action('title') },
|
||||
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
|
||||
{
|
||||
name: '/browser',
|
||||
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
|
||||
surface: action('browser'),
|
||||
args: true
|
||||
},
|
||||
|
||||
// Overlay pickers
|
||||
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
|
||||
@@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
// per reason beats 40 identical object literals.
|
||||
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
|
||||
terminal: [
|
||||
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
|
||||
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
|
||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Ink TUI — diagnostic environment flags
|
||||
|
||||
Non-secret behavioral knobs for the Ink engine (`ui-tui/`). These are
|
||||
**environment overrides**, not `.env` secrets — set them in your shell for a
|
||||
session, or `export` them in your shell rc to make them sticky. They mirror the
|
||||
OpenTUI engine's flags (`docs/opentui-env-flags.md`) so a single switch covers
|
||||
both engines.
|
||||
|
||||
| Flag | Default | What it does |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_DIAGNOSTICS` | off | Master diagnostics switch. Turning it on enables the developer/profiling surface across the TUI — including the memory self-sampler below. One `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers **every** session you start, on **either** engine. |
|
||||
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`ui-tui/src/lib/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Defaults to the master switch; set `=1` / `=0` to force it on/off independently. |
|
||||
|
||||
## What the memory trace captures
|
||||
|
||||
Each Ink session, when sampling is enabled, appends one JSON line per second to
|
||||
its own file under `~/.hermes/logs/memwatch/`, keyed by boot time + pid:
|
||||
|
||||
```json
|
||||
{"t":1781514892,"rss_kb":92148,"heap_used_kb":7234,"external_kb":2378}
|
||||
```
|
||||
|
||||
- `t` — unix seconds.
|
||||
- `rss_kb` — resident set size (the number that matters for the native-RSS-gap
|
||||
story: rss climbing while heap stays flat is the #15141-class signal).
|
||||
- `heap_used_kb` — V8 heap in use.
|
||||
- `external_kb` — off-heap (buffers, native allocations).
|
||||
|
||||
**Ink emits no `mounted` / `peak_mounted` field.** Those are OpenTUI's
|
||||
windowing dev counters; Ink has no windowing, so it logs the rss/heap/external
|
||||
core only. `memwatch-report.mjs` treats `mounted` as optional, so Ink lines
|
||||
aggregate cleanly alongside OpenTUI's.
|
||||
|
||||
## Why this exists — cross-engine memory comparison
|
||||
|
||||
The filename scheme, directory, and line schema are **byte-compatible with
|
||||
OpenTUI's collector** (`ui-opentui/src/boundary/memlog.ts`). Both engines write
|
||||
to the same `~/.hermes/logs/memwatch/` directory, so one aggregator reads both:
|
||||
|
||||
```sh
|
||||
# enable on either/both engines (master switch covers both)
|
||||
export HERMES_TUI_DIAGNOSTICS=1
|
||||
HERMES_TUI_ENGINE=ink hermes --tui # Ink session → its own .jsonl
|
||||
HERMES_TUI_ENGINE=opentui hermes --tui # OpenTUI session → its own .jsonl
|
||||
|
||||
# fleet table across BOTH engines' sessions:
|
||||
cd ~/github/tui-bench && node memwatch-report.mjs
|
||||
```
|
||||
|
||||
This is what makes a true side-by-side **real-world** memory arc possible —
|
||||
cold floor → load → plateau/leak — instead of comparing OpenTUI dogfood traces
|
||||
against an Ink harness with no equivalent data.
|
||||
|
||||
## Cost & safety
|
||||
|
||||
- ~50 bytes/s when on; one `process.memoryUsage()` + one short append per
|
||||
second. The interval is **unref'd** — it never keeps the process alive.
|
||||
- 14-day retention: older traces are pruned (best-effort) at start.
|
||||
- **Every failure path disables the logger silently.** Diagnostics must never
|
||||
break the TUI — this is the one place the "errors propagate" rule is
|
||||
intentionally inverted, matching the OpenTUI collector.
|
||||
- Off by default: regular users write nothing.
|
||||
|
||||
## Getting a meaningful trace
|
||||
|
||||
A short scroll-through won't show growth. For a comparison against OpenTUI's
|
||||
4–5h sessions, drive a tool-heavy 2–3h Ink session as the floor (see
|
||||
`docs/plans/opentui-ink-asymmetry-note.md` for why the harness ≠ dogfood data).
|
||||
@@ -0,0 +1,120 @@
|
||||
# Handoff — OpenTUI memory + UX, continuing on the canonical branch
|
||||
|
||||
**You are continuing the Hermes OpenTUI engine work.** This is the base operating manual; the
|
||||
user (glitch) appends specific tasks on top. Read it, then read the repo docs it points to. It
|
||||
assumes NO prior transcript/memory.
|
||||
|
||||
## Where things are
|
||||
|
||||
- **Canonical branch: `feat/opentui-native-engine`** (the draft PR to main, #42922).
|
||||
`feat/opentui-memory-window` is a synonym at the *same tip* — they were consolidated. Treat
|
||||
native-engine as canonical; if you work from memory-window, periodically
|
||||
`git push origin HEAD:feat/opentui-native-engine` to keep them in sync, or just use native-engine.
|
||||
- The native engine source is **`ui-opentui/`**; the legacy Ink engine is `ui-tui/` (shipping
|
||||
default, untouched by this campaign). The Python gateway is `tui_gateway/`, launcher
|
||||
`hermes_cli/main.py`.
|
||||
- **The worktree is often the user's LIVE global `hermes`** (`~/.local/bin/hermes` symlinks into a
|
||||
worktree's `.venv`). Consequences: (1) NEVER leave the worktree in a half-merged/conflicted state
|
||||
— a new `hermes` session would fail to build; (2) after you land source changes, rebuild
|
||||
`dist/main.js` so the next session picks them up; (3) `hermes-stable` is the flip-back to the
|
||||
stock `~/.hermes/hermes-agent` install if you need to bypass the worktree.
|
||||
- Backups of pre-merge branch states exist as `backup/*` refs (recoverable via `git reset`).
|
||||
|
||||
## Runtime, build, gate (Node 26 — NOT Bun; the port is done)
|
||||
|
||||
```sh
|
||||
export PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH"
|
||||
cd ui-opentui && node scripts/build.mjs # → dist/main.js (esbuild + Solid/JSX)
|
||||
HERMES_TUI_MOUSE=1 node --experimental-ffi --no-warnings dist/main.js # launch; quit = double Ctrl+C
|
||||
cd ui-opentui && npm run check # THE GATE: prettier+eslint(typed)+vitest (~700). Judge by `echo $?`, never a piped tail.
|
||||
```
|
||||
|
||||
Never run bun here. Never run `hermes update` in the worktree (it flips the branch — recovery is
|
||||
painful). Never broad-pkill tui_gateway (other live sessions). Host RAM ~15GB, often <5GB free —
|
||||
run benches SEQUENTIALLY (the harness already wraps SUTs in `systemd-run … MemoryMax=2G`).
|
||||
|
||||
## The docs that are the source of truth (read, and KEEP UPDATED as you change things)
|
||||
|
||||
- `docs/opentui-memory-story.md` — ELI5 of the whole memory architecture (primitives + every decision).
|
||||
- `docs/plans/opentui-transcript-windowing.md` — windowing design (S1 spacers, S2 append-time), the
|
||||
`correctionIsLegal` zero-jank law, pre-registered gates, SHIPPED status + S3 backlog.
|
||||
- `docs/opentui-env-flags.md` — the consolidated env-flag ledger (master switch / user / dev / plumbing).
|
||||
- `docs/opentui-upstream-alignment.md` — forkless invariant, `boundary/` shim ledger, the per-release
|
||||
OpenTUI upgrade playbook (native-yoga is coming upstream — re-tune windowing margins when it lands).
|
||||
- the bench suite (cells, harness, live-attach, memwatch) now lives in its own
|
||||
repo: **tui-bench** (`github.com/NousResearch/tui-bench`); see its `README.md`.
|
||||
- `ui-opentui/README.md` — Node 26 onboarding (fnm setup that doesn't disturb other projects).
|
||||
- `docs/plans/ink-memory-adversarial-review.md` — Ink's memory weaknesses (F1–F10, the turnabout).
|
||||
- `docs/plans/gateway-death-forensics.md`, `docs/plans/workorder-2026-06-11-results.md`,
|
||||
`docs/plans/rebase-from-main-spec.md` — forensics, the merge-bar verdict, the rebase plan.
|
||||
|
||||
## Workflow (this is how the last 60+ commits were produced with ~zero rework)
|
||||
|
||||
1. **Subagent-driven** (skill: `subagent-driven-development`): one implementer per task with a TIGHT
|
||||
file fence ("you own exactly these files; `git diff --cached --stat` before commit, abort on
|
||||
out-of-fence"), a mandatory `opentui` skill read FIRST for any renderable work, and a gate judged
|
||||
by exit code. Verify the self-report YOURSELF (re-run the gate, read the riskiest hunks, check the
|
||||
commit file-list) — a subagent "✅ done" is a claim, not a fact.
|
||||
2. **Adversarial review** after a task: a fresh read-only reviewer (Explore-type) with NAMED attack
|
||||
surfaces. Then ADJUDICATE in code — reviewers over-flag; ~half of "blockers" don't survive a read.
|
||||
3. **Parallel implementers are safe ONLY with disjoint file fences.** Read-only recon agents
|
||||
parallelize freely.
|
||||
4. **Live smoke catches what headless can't** — tmux + the `tmux-pane-screenshot` skill for real
|
||||
colored frames. The demo: `node scripts/build.mjs scripts/demo.tsx .demo` then
|
||||
`DEMO_TOTAL=2000 … node --experimental-ffi --no-warnings .demo/demo.js`.
|
||||
5. Commit format `opentui(v6): …`, **NO attribution lines**. The user's standing instruction is
|
||||
"commit + push as you land things" — honor it; otherwise don't push without asking. Edit large
|
||||
load-bearing files (the Python launcher, `store.ts`) DIRECTLY, never via subagent.
|
||||
|
||||
## Dogfooding (the user works on this FROM the hermes TUI)
|
||||
|
||||
`export HERMES_TUI_DIAGNOSTICS=1` in the shell rc turns on, for every session: the `/mem` +
|
||||
`/heapdump` slash commands, window-stats, and **fleet memory self-logging** to
|
||||
`~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Aggregate all sessions with
|
||||
`node memwatch-report.mjs` from the **tui-bench** repo
|
||||
(`github.com/NousResearch/tui-bench`) (per-session baseline/peak/slope + SLOPE/PEAK/MOUNTED anomaly
|
||||
flags). Chase a flagged session with tui-bench's `live-attach.sh <pid> --heap`. The discipline: live
|
||||
anomaly → encode as a bench cell → fix → validate against live sessions again.
|
||||
|
||||
## Current state (2026-06) + the ranked backlog
|
||||
|
||||
Windowing SHIPPED: 2k-msg peak ~300MB (was 686; Ink 234), scroll p99 6ms, cap restored 1000→3000,
|
||||
determinism digest unchanged, peak mounted ~31 rows. Live sessions peak <200MB. The transcript is no
|
||||
longer the biggest lever — the ~160MB floor is ≈104MB Node+OpenTUI runtime + **≈55MB tool/skill
|
||||
catalogs hydrated at boot**. Ranked next levers:
|
||||
|
||||
1. **W3 — 1GB V8 heap default** (small, ~free): set the unconstrained default in
|
||||
`_resolve_tui_heap_mb`; both engines are Node now so both inherit it. Ink half = separate gated
|
||||
commit (shipping engine). Measured −90MB at bench scale.
|
||||
2. **cg_peak harness fix** (small): the cgroup `memory.peak` field is polluted (shared across runs) —
|
||||
reset/scope it before quoting tui-bench's `report.html` again. Trust `vmhwm_kb` + `samples[].rss_kb`.
|
||||
3. **New bench cells** (before W1, as its baselines): `resume-1900` (real p99 shape: time-to-first-
|
||||
paint + post-hydration RSS) and `10MB-tool-output` (the F1 byte-unbounded class). Run BOTH engines.
|
||||
4. **Catalog lazy-load** (new, promoted by live data): don't hydrate 1,185 tools at boot — fetch on
|
||||
picker-open. Attacks the ≈55MB floor; pays on EVERY session (median is 20 msgs). Likely cheaper
|
||||
than W1.
|
||||
5. **W1 thin renderer** (structural, biggest): bodies live in the gateway (SQLite); TUI keeps ~300B
|
||||
stubs + fetches bodies for the window only. Design the gateway windowed-read RPC FIRST. WATCH: `/copy`
|
||||
and the ⧉ block-copy read store parts — they need a fetch-on-demand fallback or W1 ships a copy regression.
|
||||
6. **Standing**: when native-yoga OpenTUI ships, run the upgrade playbook (re-bench, re-tune margins,
|
||||
audit the shim ledger). Three questions to relay to the OpenTUI maintainer are in the alignment doc.
|
||||
|
||||
## What NOT to do
|
||||
- Don't copy opencode's 100-msg store cap (user's p90 session is 182 msgs — it would truncate normal use).
|
||||
- Don't reintroduce estimate-correction scroll jank (the user explicitly vetoed it; `correctionIsLegal` forbids it).
|
||||
- Don't cite the obsolete "~210MB bun renderer / +120MB" memory figures — pre-port, pre-windowing, wrong.
|
||||
- Don't push/PR without the standing OK; don't commit `.plans/` scratch unless asked.
|
||||
|
||||
## Suggested skills
|
||||
(All available from the Hermes TUI agent too — this is the dogfooding surface. Curated to the load-bearing set, not the full ~40-skill catalog.)
|
||||
- `opentui-tui-engineering` — the workflow/architecture/pitfalls layer for `ui-opentui/` (just updated).
|
||||
- `hermes-tui-architecture` — the Hermes-specific TUI facts (launch pipeline, both engines; just updated).
|
||||
- `opentui` — the offline renderable-API doc set; mandatory `skill_view` before any view/renderable code.
|
||||
- `subagent-driven-development` — the process spine for parallel/heavy work.
|
||||
- `tmux-pane-screenshot` — real colored PNG of a tmux pane for visual verification (ported
|
||||
into hermes skills 2026-06-13). Use: `bash ~/.hermes/skills/software-development/
|
||||
tmux-pane-screenshot/scripts/tshot.sh <session:win.pane> out.png 2`, then Read the PNG.
|
||||
`freeze` (~/go/bin) + the resvg rasterizer are shared/system-wide — works as-is.
|
||||
- `effect-ts` — for the Effect-at-boundary entry/lifecycle code.
|
||||
- `superpowers:brainstorming` — before committing to a memory-architecture design (e.g. W1's store split).
|
||||
- `systematic-debugging` — if a gate fails; root-cause before patching.
|
||||
@@ -0,0 +1,81 @@
|
||||
# OpenTUI env flags — the consolidated ledger
|
||||
|
||||
Every environment variable the OpenTUI TUI reads (grep-verified 2026-06-12),
|
||||
classified by who should ever touch it. The design rule shipped with this doc:
|
||||
**regular users see zero diagnostic surface by default; one master switch
|
||||
(`HERMES_TUI_DIAGNOSTICS=1`) turns all of it on when needed.**
|
||||
|
||||
## 1. The master switch
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_DIAGNOSTICS` | **off** | Enables the diagnostic slash commands (`/mem`, `/heapdump`). While off they're hidden from `/help` (client-side filter) and invoking them prints the enable hint rather than executing. They never appear in slash *completion* in either state — completion is gateway-driven and these are client-only commands the gateway doesn't know (an adversarial review confirmed there's no bypass path; if a SERVER command named `mem`/`heapdump` is ever added it must be gated gateway-side too — the client gate would shadow but not hide it). Also flips the *default* of `HERMES_TUI_WINDOW_STATS` to on. Not a secret — support flows are "relaunch with `HERMES_TUI_DIAGNOSTICS=1`". |
|
||||
|
||||
## 2. User-facing configuration (fine to document publicly)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_ENGINE` | auto (`opentui` if Node≥26.3 + built, else `ink`) | Engine pick; also `display.tui_engine` in config.yaml. |
|
||||
| `HERMES_TUI_MOUSE` / `HERMES_TUI_MOUSE_TRACKING` / `HERMES_TUI_DISABLE_MOUSE` | on | Mouse support (wheel scroll, selection, click-to-expand). **Defers to Ink's env surface (`logic/env.ts` `resolveMouseEnabled`):** precedence is `HERMES_TUI_MOUSE_TRACKING` (toggle, force knob) > `HERMES_TUI_DISABLE_MOUSE=1` (legacy kill switch) > `HERMES_TUI_MOUSE` (OpenTUI-native alias, kept — also what the launcher sets) > default on. OpenTUI's renderer mouse is a single boolean, so Ink's granular off\|wheel\|buttons\|all collapses to on/off (the granular mode lives in `display.mouse_tracking` config). |
|
||||
| `HERMES_TUI_SCROLL_SPEED` (alias `CLAUDE_CODE_SCROLL_SPEED`) | native | Wheel-scroll speed multiplier (Ink parity). UNSET → OpenTUI's native scroll acceleration (untouched). A positive value (clamped to (0,20]) installs a constant-multiplier `ScrollAcceleration` on the transcript scrollbox (`view/transcript.tsx`). |
|
||||
| `HERMES_TUI_NO_CONFIRM` | off | Skip the destructive-action confirm step (`/clear`, `/new`) and run immediately (Ink parity, `NO_CONFIRM_DESTRUCTIVE`). Wired at the `confirm` seam (`entry/main.tsx`). |
|
||||
| `HERMES_TUI_MAX_MESSAGES` | ceiling | Scrollback rows kept in the TUI. Can LOWER the ceiling, never raise: 3000 with windowing, 1000 with windowing off (handle-table safety). |
|
||||
| `HERMES_TUI_TOOL_OUTPUT_LINES` | unlimited | Cap expanded tool-output lines (set a number to restore a cap). |
|
||||
| `HERMES_TUI_TOOL_OUTPUTS` | **on** | Keep rich tool-call OUTPUTS (full result body + raw result/args dicts). `=off` drops both the RENDER and the STORE of those bodies (Ink parity: only a one-line context preview + name/duration/error/diff survive) — the memory lever for the OpenTUI-vs-Ink retention asymmetry, and what the bench launches OpenTUI with for the fair engine-overhead comparison (W3). Diffs (file-edit) are KEPT either way. |
|
||||
| `HERMES_TUI_HEAP_MB` | cgroup-aware (default 8192) | V8 `--max-old-space-size` (MB) for BOTH engines. Highest precedence (then `display.tui_heap_mb` config, then the cgroup-75% fallback). Set it LOW for a low-mem session (still cgroup-clamped on top so it never exceeds the container); raise it to lift the ceiling. The low-mem opt-in signal that also arms `HERMES_TUI_PROACTIVE_GC` (W1). |
|
||||
| `HERMES_TUI_PROACTIVE_GC` | = low-`HERMES_TUI_HEAP_MB` (≤4096) | Idle-gated `global.gc()` for the low-mem path. Defaults ON only when a low heap cap is set (so the knobs compose); `=on`/`=off` forces it. Needs `--expose-gc` (the OpenTUI argv now carries it). Never runs mid-stream; tightens cadence above 400MB RSS but stays idle-gated. OpenTUI-only — Ink never GCs proactively (W2). |
|
||||
| `HERMES_TUI_COMPOSER_ROWS` | default rows | Composer height. |
|
||||
|
||||
## 3. Escape hatches & tuning (dev-facing, individually settable)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_WINDOWING` | **on** | `0` = bit-exact pre-windowing renderer (every row mounts; cap clamps back to 1000). The A/B + regression escape hatch. |
|
||||
| `HERMES_TUI_WINDOW_IDLE_MS` | ~1000 | Idle-measure pulse cadence (the spacer-exactness march). Test knob. |
|
||||
| `HERMES_TUI_WINDOW_STATS` | = `HERMES_TUI_DIAGNOSTICS` | Exposes live/peak mounted-row counters (`globalThis.__hermesTuiWindowStats`) for tui-bench's live-attach reads. |
|
||||
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`boundary/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl` (rss/heap/external + mounted rows; 14-day retention). Fleet view: `node memwatch-report.mjs` from the tui-bench repo (`github.com/NousResearch/tui-bench`). The "monitor all my sessions" answer: one `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers every session. |
|
||||
| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. |
|
||||
| `HERMES_HEAPDUMP_ON_START` | off | Write one V8 heap snapshot at boot (Ink parity). A deliberate baseline-capture escape hatch that BYPASSES the diagnostics master switch; lands at `$HERMES_HOME/logs/opentui-heap-<ts>.heapsnapshot` and echoes the path as a system line (`entry/main.tsx`). |
|
||||
| `HERMES_TUI_NOTIFY` | on | Desktop-notification kill switch (`=0`/`false`/`off` silences the "waiting on you" pings). The ping itself goes through the renderer's native `triggerNotification` (protocol detection + tmux/Zellij wrapping); the window title is not gated by this. |
|
||||
|
||||
## 4. Internal plumbing (set by the launcher/tui-bench/tests — humans never set these)
|
||||
|
||||
| var | set by | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). |
|
||||
| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. |
|
||||
| `HERMES_TUI_RESUME`, `HERMES_TUI_QUERY`, `HERMES_TUI_PROMPT`, `HERMES_TUI_IMAGE`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot; seeded prompt (`--tui "prompt"`: launcher sets `HERMES_TUI_QUERY`, the engine reads QUERY > the `HERMES_TUI_PROMPT` alias > a bare argv tail — `logic/env.ts` `startupPrompt`); seeded image PATH (`--image`: `HERMES_TUI_IMAGE`, `image.attach`ed before the prompt — `startupImage`, attach in `postSessionSetup`); fake-mode. |
|
||||
| `HERMES_AUTO_HEAPDUMP*` (`_COOLDOWN_MS`/`_MAX_BYTES`), `HERMES_HEAPDUMP_DIR`, `HERMES_HEAPDUMP_MAX_BYTES` | — | **NOT read by the OpenTUI engine (deliberate).** The engine ports Ink's #34095 silent-death early-WARNING (a transcript system line, `boundary/memoryMonitor.ts`) but NOT the auto heap-SNAPSHOT capture — the always-on memlog NDJSON trace is the diagnosis path, and its rss-vs-heap divergence is the better diagnostic for the native-RSS leak class (#15141) a V8 snapshot captures poorly. So the #41948 disk-fill safety set (gate/cooldown/byte-cap/dir) has no consumer here. `HERMES_HEAPDUMP_ON_START` (manual one-shot, §3) is the only heapdump knob the engine honors. |
|
||||
| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. |
|
||||
| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. |
|
||||
|
||||
## 5. Ink flags NOT ported — handled natively or out of scope
|
||||
|
||||
These exist on the legacy Ink TUI (`ui-tui/`) and are deliberately **not** read
|
||||
by the OpenTUI engine. Documented so a missing flag reads as a decision, not a gap.
|
||||
|
||||
| Ink flag | why not ported |
|
||||
|---|---|
|
||||
| `HERMES_TUI_TRUECOLOR` | OpenTUI core does COLORTERM/truecolor detection natively — the Ink force-truecolor hack is a fork workaround we shed. |
|
||||
| `HERMES_TUI_FORCE_OSC52` | OpenTUI core owns OSC52 clipboard as a primitive; no fallback hint needed. |
|
||||
| `HERMES_TUI_INLINE` / `HERMES_TUI_TERMUX_MODE` / `HERMES_TUI_TERMUX_FAST_ECHO` | Termux/primary-buffer accommodations. OpenTUI's native FFI floor (Node ≥26.3 + `--experimental-ffi`) is absent on Termux, so those sessions stay on **Ink** — these are correctly N/A for the OpenTUI engine. |
|
||||
| `HERMES_TUI_FPS` | Ink FPS overlay; the OpenTUI equivalent is the diag/window-stats surface (`HERMES_TUI_WINDOW_STATS`). Not parity-critical. |
|
||||
| `HERMES_DEV_CREDITS` / `HERMES_DEV_PERF*` | Dev-only throwaway scaffolding (live-spend readout, perf logging) — not user parity. |
|
||||
| `HERMES_BIN` / `HERMES_TUI_GATEWAY_URL` / `HERMES_TUI_SIDECAR_URL` | External-CLI / remote-gateway-URL overrides. OpenTUI spawns its gateway via the Effect boundary (`liveGateway.ts`) and does not shell out to `hermes` or take an external gateway URL. |
|
||||
| `HERMES_VOICE` | Voice mode is tracked on the OpenTUI parity backlog separately, not here. |
|
||||
|
||||
## How the pieces compose (the support script)
|
||||
|
||||
- Regular user, normal day: zero flags, zero diagnostic commands visible.
|
||||
- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes` → `/mem`
|
||||
for the live numbers, `/heapdump` for a snapshot to attach, window stats
|
||||
exposed for tui-bench's `live-attach.sh <pid>` to read.
|
||||
- Developer profiling: same master switch + the individual knobs
|
||||
(`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed.
|
||||
- Anything in section 4 appearing in a user-facing doc is a bug.
|
||||
|
||||
Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`),
|
||||
`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion
|
||||
filtering), `view/transcript.tsx` (stats default). Tests:
|
||||
`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands
|
||||
themselves, gate enabled suite-wide).
|
||||
@@ -0,0 +1,207 @@
|
||||
# How the OpenTUI transcript got from 686MB to ~300MB — the full story
|
||||
|
||||
*For: glitch. Branch: `feat/opentui-memory-window`. Everything here is measured,
|
||||
not vibes; every number has a result JSON in the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`).*
|
||||
|
||||
---
|
||||
|
||||
## 1. The cast of characters (the primitives, bottom-up)
|
||||
|
||||
To understand where the memory went, you need to know who's holding it. Six
|
||||
layers, from the screen up:
|
||||
|
||||
**The terminal grid.** Your terminal is a spreadsheet of character cells.
|
||||
Nobody pays per-message here — tmux holds ~5MB flat no matter how long the
|
||||
session is (we measured). The terminal is never the problem.
|
||||
|
||||
**The OpenTUI native renderer (Zig).** A compiled library that owns the
|
||||
"frame buffer" — the grid of cells about to be painted. Every piece of text the
|
||||
TUI shows lives in a native **TextBuffer** (the characters + their colors),
|
||||
viewed through a **TextBufferView**, styled by a **SyntaxStyle**. Each of those
|
||||
is a **native handle** — a ticket into one global table that has only **65,535
|
||||
slots, total, ever** (16-bit indices — like a coat check with 65k hooks).
|
||||
Destroying a renderable returns its tickets, so the constraint is not "how much
|
||||
have you ever created" but **"how much is alive right now."**
|
||||
|
||||
**Renderables.** OpenTUI's UI objects — `<text>`, `<box>`, `<markdown>`,
|
||||
`<code>`, `<scrollbox>`. One transcript row (a message with its tool calls,
|
||||
markdown, code blocks, copy chips) is a *tree* of these: **~16 text renderables
|
||||
≈ 47 native handles ≈ ~250–340KB of RSS, per row.** This is the number that
|
||||
drives everything. 1,400 mounted rows × 47 handles = table full = the crash we
|
||||
root-caused last week.
|
||||
|
||||
**Yoga (the layout engine, WASM).** Every renderable also has a Yoga node —
|
||||
Yoga is the flexbox calculator that decides where boxes go. OpenTUI ships it
|
||||
compiled to **WebAssembly**, and WASM has a brutal property: its memory can
|
||||
**grow but never shrink** back to the OS. So the peak number of
|
||||
*simultaneously-mounted* renderables sets a high-water mark you pay **forever**,
|
||||
even after everything is destroyed. (Fun fact from this week's forensics: we
|
||||
spent two days believing Ink had this disease. It doesn't — our Ink fork swapped
|
||||
Yoga-WASM for a plain TypeScript port at fork creation. **We** are the ones
|
||||
running layout in WASM. The accusation was true; we just had the defendant
|
||||
wrong.)
|
||||
|
||||
**Solid (the view framework).** Renders each store message into a row via
|
||||
`<For>`. The property we exploit: Solid mounts/unmounts *surgically* — remove a
|
||||
row from what the component returns and Solid destroys exactly that row's
|
||||
renderables (returning its handles and freeing its Yoga nodes), touching
|
||||
nothing else. No virtual-DOM diffing, no collateral re-renders.
|
||||
|
||||
**V8 (the JavaScript engine) + the store.** The store keeps every message as JS
|
||||
strings/objects. V8's garbage collector is *lazy by design*: with the default
|
||||
8GB ceiling we launch with, it sees no reason to clean up aggressively, so RSS
|
||||
includes a lot of "collectible but not yet collected" garbage. Cheap to fix,
|
||||
worth real MB (measured below).
|
||||
|
||||
**The scrollbox.** One detail that fooled everyone at some point:
|
||||
`viewportCulling` (on by default) skips *drawing* offscreen rows — but they stay
|
||||
fully **mounted**: handles held, Yoga nodes alive, memory paid. Culling saves
|
||||
paint time, not memory. That misunderstanding is half the reason the "rolling
|
||||
store cap" was expected to be enough, and wasn't.
|
||||
|
||||
## 2. Why it was 686MB
|
||||
|
||||
Simple arithmetic. The old TUI mounted **every message in the store** as a full
|
||||
renderable tree. 2,000 messages × ~16 renderables × (handles + Yoga nodes +
|
||||
text buffers + V8 objects) ≈ 670–690MB, growing ~300MB per 1,000 messages. And
|
||||
at ~1,400 rows the handle table filled: first a hard crash (exit 7), then —
|
||||
after our containment fix — survival with **unstyled text** past that point,
|
||||
plus a cap clamped from 3,000 rows down to 1,000 as the price of not crashing.
|
||||
|
||||
Ink, meanwhile, sat at ~234MB at the same workload, because Ink only ever
|
||||
mounts the rows near your viewport (~84–400 live nodes). Its memory is the
|
||||
*data* plus some caches — not the *view*.
|
||||
|
||||
## 3. The decisions, in order
|
||||
|
||||
### Decision 1: virtualize the view, don't starve the store
|
||||
|
||||
Two ways to cut view memory: keep fewer messages (opencode's answer — they keep
|
||||
100 and delete the rest from memory; transcript truth lives on their server), or
|
||||
keep all messages but only *materialize* the ones near the viewport. You vetoed
|
||||
the first (your p90 session is 182 messages — a 100-row store truncates normal
|
||||
sessions), so: **windowing**. Notably the OpenTUI devs confirmed this week that
|
||||
framework-level virtualization is the intended path — the engine doesn't ship
|
||||
it out of the box, and opencode never built it. We did.
|
||||
|
||||
### Decision 2: exact heights, recorded at unmount — never estimates in your face
|
||||
|
||||
This is the load-bearing idea, and it's where we beat Ink at its own game.
|
||||
|
||||
The hard problem of any virtualized list: an unmounted row still needs to
|
||||
occupy its correct *height*, or the scrollbar lies and content jumps. Ink
|
||||
solves it by **guessing** heights and correcting after measurement — those
|
||||
corrections are precisely the 83–101ms scroll stutters you hate. You explicitly
|
||||
vetoed "estimate-correction jank" as a model.
|
||||
|
||||
Our advantage: OpenTUI lays out with real, queryable heights. So when a row
|
||||
scrolls out of the window, we record its **exact laid-out height** (an
|
||||
`onSizeChange` hook fires inside layout, pre-paint) and replace the row with an
|
||||
empty `<box height={exactly-that}/>` — a **spacer**: one Yoga node, zero text
|
||||
buffers, zero native handles. Think of a bookshelf where books you're not
|
||||
reading are swapped for cardboard sleeves cut to *exactly* the book's
|
||||
thickness: the shelf never shifts, and you can't tell from across the room.
|
||||
|
||||
The window is your viewport ± one viewport of margin (plus hysteresis so it
|
||||
doesn't thrash at the edges). Scroll near a spacer and the real row remounts —
|
||||
at the recorded height, so nothing moves.
|
||||
|
||||
And one **law**, written into the code as `correctionIsLegal`: a spacer's
|
||||
height may only ever be corrected where you *cannot see it* — fully above the
|
||||
viewport (with the scroll position compensated in the same frame, so the world
|
||||
doesn't move) or fully below it. A correction that would shift visible content
|
||||
is forbidden, structurally. Jank isn't tuned down; it's outlawed.
|
||||
|
||||
### Decision 3 (the S2 insight): adjudicate on *append*, not just on scroll
|
||||
|
||||
S1 alone got 686 → 518MB. Why not more? Because of *when* windowing decided.
|
||||
S1 re-decided the window when you **scrolled**. But during a streaming burst —
|
||||
an agent turn dumping hundreds of rows — you don't scroll; rows arrive, each
|
||||
mounting fully, and only get demoted later. That transient pile-up is mostly
|
||||
invisible in steady-state numbers… except for Yoga-WASM, where **the transient
|
||||
peak is permanent** (memory never shrinks). The burst was quietly ratcheting
|
||||
the floor.
|
||||
|
||||
S2 makes the window recompute on **transcript growth**: while you're pinned at
|
||||
the bottom, the window anchors to the content *bottom*, so a row that falls
|
||||
more than a margin behind the live edge becomes a spacer the moment it's
|
||||
measured — not whenever you next scroll. Measured result: across a 1,500-row
|
||||
burst, the peak number of simultaneously-mounted rows is **31**.
|
||||
|
||||
Same trick for **resume**: opening a 2,000-message session used to mount all of
|
||||
it (transient peak again — paid forever). Now resume mounts only the bottom
|
||||
window; everything above starts as spacers using a line-count estimate, and an
|
||||
idle-time "measure march" quietly mounts ten rows at a time near the window
|
||||
edge, records their true heights, and swaps them back — all outside the
|
||||
viewport, all invisible by the law above.
|
||||
|
||||
### Decision 4: rows that must never be windowed
|
||||
|
||||
Windowing has to know what it's not allowed to touch:
|
||||
- **Streaming rows** — the native markdown renderer streams incrementally;
|
||||
unmounting mid-stream would restart it visibly.
|
||||
- **The bottom 30 rows** — the region you actually live in.
|
||||
- **Rows under a mouse selection** — the review caught that a lingering
|
||||
highlight originally froze windowing *forever* (memory regrowing silently).
|
||||
Fixed: only an active drag pauses swaps, and selected rows get pinned, so
|
||||
copy is byte-exact while everything else keeps windowing.
|
||||
|
||||
### Decision 5: give back the scrollback (cap 1,000 → 3,000)
|
||||
|
||||
The 1,000-row clamp existed only because mounted-rows == stored-rows and the
|
||||
handle table dies at ~1,400. With windowing, mounted ≈ 31 regardless of store
|
||||
size — so the cap went back to the originally-shipped 3,000. It's
|
||||
windowing-aware: the `HERMES_TUI_WINDOWING=0` escape hatch (which mounts
|
||||
everything again) keeps the safe 1,000.
|
||||
|
||||
### Decision 6 (measured, not yet shipped as default): right-size the V8 heap
|
||||
|
||||
Running the windowed TUI with a 512MB heap ceiling instead of 8GB forced V8 to
|
||||
actually collect: another −90MB with zero latency cost. That's queued as a
|
||||
launcher default change (~1GB), for both engines.
|
||||
|
||||
## 4. The scoreboard
|
||||
|
||||
At 2,000 messages (your real p99 session size — yes, we checked your DB:
|
||||
median session is 20 messages, p99 is 1,941):
|
||||
|
||||
| | peak memory | scroll p99 (slowest 1-in-100) |
|
||||
|---|---|---|
|
||||
| OpenTUI before | 686MB | 16ms |
|
||||
| + S1 windowing | 518MB | 16ms |
|
||||
| + S2 append/resume windowing | **300–375MB** | **6ms** |
|
||||
| Ink (reference) | 229–246MB | ~100ms |
|
||||
|
||||
At the **3,000-message stress** with the restored triple-size scrollback:
|
||||
**360MB, fully styled, scroll p99 8ms** — a workload that six days ago crashed
|
||||
the process, and three days ago survived only by dropping syntax colors.
|
||||
|
||||
Scroll got *faster* because there are simply fewer live renderables to walk.
|
||||
The determinism gate stayed **byte-identical** — the windowed TUI's settled
|
||||
frame is provably the same pixels as before. And the live smoke (2,000-message
|
||||
session: full sweep to the top, resize storm, back to bottom) returned a frame
|
||||
pixel-identical to boot, with deep history fully syntax-highlighted — something
|
||||
the pre-windowing TUI literally could not do.
|
||||
|
||||
## 5. What's honestly still open
|
||||
|
||||
- The remaining ~60–120MB over Ink is mostly the **store's JS strings** and
|
||||
process baseline — the view is no longer the problem. The structural fix is
|
||||
the **thin renderer** (W1): bodies live in the Python gateway (which already
|
||||
has them in SQLite); the TUI keeps ~300-byte stubs and fetches bodies only
|
||||
for the window. That also fixes the class of problem neither engine handles
|
||||
today: a single 10MB tool output.
|
||||
- Two accepted, documented limits: scrollbar-*jumping* deep into a freshly
|
||||
resumed session can land on estimate-height rows that snap to true height as
|
||||
they enter view (normal scrolling doesn't — the margin pre-measures; the idle
|
||||
march erodes the exposure over time), and a tool you expanded, scrolled far
|
||||
away from, then returned to will have re-collapsed (state is component-local;
|
||||
hoisting it to the store is queued).
|
||||
- Everything is behind `HERMES_TUI_WINDOWING` (default on, `0` = bit-exact old
|
||||
behavior) — a one-env escape hatch if anything feels off in real use.
|
||||
|
||||
*Where to verify: the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`; every number above), the design+gates doc
|
||||
`docs/plans/opentui-transcript-windowing.md`, tests in
|
||||
`ui-opentui/src/test/window.test.ts` and `transcriptWindow.test.tsx` (the
|
||||
zero-jank invariants are literal assertions: identical scrollHeight windowed
|
||||
vs not, byte-stable frames across corrections).*
|
||||
@@ -0,0 +1,432 @@
|
||||
# OpenTUI native engine — PR documentation
|
||||
|
||||
**Branch:** `feat/opentui-native-engine` · **Base:** `origin/main` (merged in; HEAD is at `~main`)
|
||||
**New engine root:** `ui-opentui/` (Node 26 + `@opentui/core` 0.4.1 + `@opentui/solid`, Effect at the boundary)
|
||||
**Legacy engine root:** `ui-tui/` (React + the `@hermes/ink` fork at `ui-tui/packages/hermes-ink/`)
|
||||
|
||||
> This is the canonical in-repo doc for the PR. The companion interactive HTML
|
||||
> write-up (`~/projects/opentui-perf-writeup/index.html`) is the case/benchmark
|
||||
> deep-dive; this doc is the reviewable text version + the four things review
|
||||
> actually needs: **(1) the LoC reduction math, (2) the measured perf deltas,
|
||||
> (3) the real UI divergence (with screenshots), (4) the non-core / kitchen-sink
|
||||
> change audit.**
|
||||
|
||||
This PR adds a from-scratch native terminal UI built on OpenTUI, intended to
|
||||
replace the React/Ink TUI **and the Ink fork we maintain alone**. It currently
|
||||
ships as a parallel engine (Ink untouched, auto-fallback), selected by
|
||||
`HERMES_TUI_ENGINE` env > `display.tui_engine` config > auto (OpenTUI when the
|
||||
host is Node ≥ 26.3 with the built bundle, else Ink). **100% parity with the Ink
|
||||
TUI is the bar.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Line-of-code reduction (the headline maintenance win)
|
||||
|
||||
All counts are **git-tracked files only** (respects `.gitignore`; `dist/` and
|
||||
`node_modules/` are untracked and excluded). Measured live on this branch at
|
||||
`~HEAD`. "Code" = `.ts/.tsx/.js/.jsx` only; "total" includes config/json/md.
|
||||
|
||||
### What gets *removed* when Ink is retired
|
||||
|
||||
| Area | Files | Total lines | Code lines (ts/tsx/js) | Non-blank code |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `ui-tui/src/` — Ink **consumer app** (our React/Ink view code) | 204 | 40,422 | 40,422 | 33,550 |
|
||||
| `ui-tui/packages/hermes-ink/` — **the fork** (`@hermes/ink`) | 148 | 28,167 | 28,113 | 23,718 |
|
||||
| **`ui-tui/` whole tree (tracked)** | **362** | **69,320** | **68,831** | **57,545** |
|
||||
|
||||
The `ui-tui/` whole-tree number (69,320) also folds in a handful of build
|
||||
scripts, `.prettierrc`, `package.json`, etc. The two rows above it are the
|
||||
load-bearing split:
|
||||
|
||||
- **The fork alone is 28,167 LOC across 148 files** — code we own and can never
|
||||
sync from upstream. Upstream Ink v6.8.0 `src/` is ~7,259 LOC, so the fork's
|
||||
renderer core is **~3.2× the size of stock Ink**. (Cross-checked against the
|
||||
HTML write-up's `ink-fork-analysis.json`: 28,111 LOC / 148 files — the 56-line
|
||||
delta is a single tracked JSON the file-level count includes.)
|
||||
- **The consumer app is another 40,422 LOC** — React components/hooks that only
|
||||
exist to drive Ink.
|
||||
|
||||
### What gets *added*
|
||||
|
||||
| Area | Files | Total lines | Code lines | Non-blank code |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `ui-opentui/src/` — new engine (app code **+ its own tests**) | 153 | 28,763 | 28,763 | 26,495 |
|
||||
| ↳ non-test (app code only) | 97 | 16,628 | 16,628 | 15,450 |
|
||||
| ↳ tests (`src/test/`) | 56 | 12,135 | 12,135 | 11,045 |
|
||||
| Tree-sitter grammars (`python`…`toml`) | 0 | 0 | 0 | 0 |
|
||||
| **`ui-opentui/` whole tree (tracked)** | **~170** | **~34,800** | **29,614** | **27,283** |
|
||||
|
||||
> Tree-sitter grammars carry **zero repo lines**: the engine declares the 10
|
||||
> extra grammars as remote URLs (`src/boundary/parsers.manifest.json`) and
|
||||
> OpenTUI fetches+caches each `.wasm`/`.scm` on first use into
|
||||
> `~/.hermes/cache/opentui-parsers/` (à la opencode, which vendors none). An
|
||||
> earlier revision vendored them as 37,302 checked-in binary lines (10 `.wasm` +
|
||||
> 10 `.scm`); that's gone — code lines and total lines now move together.
|
||||
|
||||
### The net reduction (code lines, the honest comparison)
|
||||
|
||||
| Comparison | Removed (ts/tsx/js) | Added (ts/tsx/js) | Net change |
|
||||
|---|---:|---:|---:|
|
||||
| **Incl. fork** — retire all of `ui-tui/` vs add `ui-opentui/src` | −68,831 | +28,763 | **−40,068 LOC (−58%)** |
|
||||
| **Incl. fork, app-vs-app** (exclude both test suites) | −56,463¹ | +16,628 | **−39,835 LOC (−71%)** |
|
||||
| **Excl. fork** — only the Ink *consumer app* vs new engine | −40,422 | +28,763 | **−11,659 LOC (−29%)** |
|
||||
| **The fork in isolation** (the unsyncable liability we shed) | −28,113 | — | **−28,113 code lines deleted outright (28,167 incl. its 1 config file)** |
|
||||
|
||||
¹ `ui-tui/src` non-test = 28,350 LOC + fork (≈ all 28,113 code lines are non-test;
|
||||
it carries only ~54 config lines) = 56,463. (`ui-tui/src` carries 80 test files /
|
||||
12,072 LOC; the new engine carries 56 test files / 12,135 LOC.)
|
||||
|
||||
**Read it this way:**
|
||||
|
||||
- **The cleanest single number: ~−40k code lines net** (retire all of `ui-tui/`,
|
||||
add `ui-opentui/src`). That is a **~58% reduction in the TUI's
|
||||
hand-maintained surface**, and it *includes* the new engine's full 56-file test
|
||||
suite.
|
||||
- **The most important number is the fork: −28,167 LOC of unsyncable engine
|
||||
code** disappears. That is the load-bearing maintenance win — it's not just
|
||||
fewer lines, it's lines we are the *sole* maintainer of (own reconciler, ANSI
|
||||
parser, scrollbox, selection/OSC52, hand-rolled memory eviction, Yoga binding).
|
||||
- **Even excluding the fork** — i.e. if you imagine upstream Ink were free — the
|
||||
app rewrite is still a net reduction (−11,659 LOC) because the new engine
|
||||
mounts OpenTUI built-ins instead of hand-building components.
|
||||
|
||||
### Caveat on the comparison (keep it honest for review)
|
||||
|
||||
- These are **whole-tree retirements vs a single source dir add.** If/when Ink is
|
||||
deleted, the `ui-tui/` `package.json`, lockfile, and build scripts go too; the
|
||||
table counts `ui-tui/src` + the fork as the apples-to-apples "hand-maintained
|
||||
TS" figure.
|
||||
- **Tree-sitter grammars are NOT vendored.** The 10 extra grammars are declared
|
||||
as remote URLs (`src/boundary/parsers.manifest.json`); OpenTUI fetches each
|
||||
`.wasm`/`.scm` on first use of a language and caches it under
|
||||
`~/.hermes/cache/opentui-parsers/` (profile-aware, set via
|
||||
`HERMES_TUI_PARSER_CACHE` by the launcher). Registration does **zero** network;
|
||||
the fetch is lazy and off the boot critical path, and an unreachable
|
||||
GitHub/air-gapped env degrades that language to plain text — never a throw. This
|
||||
replaces an earlier revision that vendored 37k binary lines, so the repo no
|
||||
longer grows on disk for syntax highlighting. (Trade-off: first-use-per-language
|
||||
needs network to `github.com`/`raw.githubusercontent.com`; pre-seed the cache in
|
||||
a Docker build if you need offline highlighting.)
|
||||
- Python/backend LoC is **not** part of this reduction: `tui_gateway/` (~12k LOC)
|
||||
is **shared by both engines** and stays. See §4.
|
||||
|
||||
---
|
||||
|
||||
## 2. Performance (CPU / latency / memory)
|
||||
|
||||
Measured with the `tui-bench` harness driving **both engines on a real PTY
|
||||
120×40**, fake gateway feeding deterministic events, `/proc`-sampled identically,
|
||||
each SUT under `systemd-run --scope -p MemoryMax=2G -p MemorySwapMax=0`,
|
||||
sequential with a load-gate + 10s cooldown. Determinism gate **GREEN**, 71 result
|
||||
files, 0 cell errors, 3 reps/cell, `@opentui/core` 0.4.1 native-yoga
|
||||
(`libopentui.so`, no `yoga.wasm`). Every number traces to a `summary.<field>` in
|
||||
a result dir. Source: `~/projects/opentui-html/bench-numbers.json` (frozen
|
||||
2026-06-14, build under test `1ddf7a102` + WIP).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Winner | Margin | Source cell |
|
||||
|---|---|---|---|
|
||||
| Streaming frame rate | **OpenTUI** | **~3×** (43 vs 14 fps) | `cpu800.frame_pacing` |
|
||||
| Streaming smoothness (interframe p95) | **OpenTUI** | **40ms vs ~220ms** (no ¼-second stalls) | `cpu800.frame_pacing` |
|
||||
| Scroll CPU | **OpenTUI** | **~2.7× cheaper** (134–155 vs 403–416 ticks) | `scroll3000.scroll.cpu_ticks` |
|
||||
| Cold-start floor | **OpenTUI** | ~97–103 vs ~107–109 MB | `startup.vmhwm_kb` |
|
||||
| Session-create latency | **OpenTUI** | ~151–177 vs ~204–229 ms | `startup.session_create_ms` |
|
||||
| First-byte paint | Ink | ~93 vs ~122 ms | `startup.first_byte_ms` |
|
||||
| Memory @ small/typical | Ink | OpenTUI +30–50 MB | `mem50/100/300.vmhwm` |
|
||||
| Memory @ heavy tool output | **OpenTUI** | **crossover** (258–265 vs 280–290 MB) | `results-fat-mem-*` |
|
||||
| Layout reflow latency | **Ink** | **~0ms vs ~13ms** (OpenTUI's one honest loss) | `resize3000.resize.reflow_ms` |
|
||||
|
||||
### The honest reading
|
||||
|
||||
- **OpenTUI wins everything you feel continuously** — frame rate (~3×), scroll
|
||||
CPU (~2.7×), and smoothness (no 200ms hitches; p95 40ms vs ~220ms). This is the
|
||||
lead. The single most user-perceptible difference is the stall-free stream.
|
||||
- **Memory: lead with smoothness, not raw RSS.** Ink is lighter at small/typical
|
||||
sizes (OpenTUI carries a ~102 MB irreducible Node+V8+`libopentui.so` floor, so
|
||||
it sits +30–50 MB above Ink there). But it **crosses over** under heavy tool
|
||||
output (mem300: 258–265 MB OpenTUI vs 280–290 MB Ink) because windowing beats
|
||||
Ink's mount-every-row. Real-world: 20 memwatch sessions show a flat ~108 MB
|
||||
floor and ~0 MB/h on long sessions (one 15h session, 0 MB/h; one 4.4h session
|
||||
plateaus flat at ~237 MB with mounted rows pinned at 33).
|
||||
- **The one outright loss is layout reflow** (~13ms p50 vs Ink's ~0ms; under a
|
||||
resize storm OpenTUI degrades to ~14fps/~197ms vs Ink ~26fps/~100ms). Heavier
|
||||
native renderables vs Ink's string nodes. This is a real, quantified
|
||||
optimization target — **not** a regression vs current behavior, and **not** the
|
||||
"halved 0.4.0→0.4.1" delta (we measured the absolute 12–15ms only; do not quote
|
||||
"halved" from this run).
|
||||
- **The memory fix is engine-agnostic** — a rolling display cap
|
||||
(`HERMES_TUI_MAX_MESSAGES=3000` default) that is display-only and never touches
|
||||
the model's context. Uncapped is a stress config, not real usage (10k msgs
|
||||
uncapped: 793 MB; capped sessions are flat MB/h).
|
||||
- **Gut-check vs upstream/opencode: no bugs.** Exactly one frame callback
|
||||
(early-exits cheaply), zero `writeToScrollback` for the transcript (one sticky
|
||||
`<scrollbox>` + reactive `<For>`), native `<markdown streaming>` byte-for-byte
|
||||
parity with live opencode, no reactive-read-outside-tracking-scope (the #1 Solid
|
||||
trap). Source: `docs/plans/opentui-gutcheck-verification.md`.
|
||||
|
||||
Full methodology + every cell: see the HTML write-up's benchmark sections and
|
||||
`docs/plans/opentui-endgame-benchmark-report.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI parity — and where the two engines genuinely diverge visually
|
||||
|
||||
100% *feature* parity is the bar (matrix in §6), but the two engines are **not**
|
||||
visually identical. The Ink TUI renders the transcript as a **box-drawing tree**;
|
||||
OpenTUI renders it **flat and marker-based**. This is a deliberate design
|
||||
divergence, captured in `ui-opentui/src/view/messageLine.tsx`:
|
||||
|
||||
> *"the view is a dark room and gold is the single lamp — it sits on the NEWEST
|
||||
> answer's `⚕` and the user's `❯`, nowhere else (older assistant glyphs demote to
|
||||
> grey: they merely happened)."*
|
||||
|
||||
Real screenshots (saved under `docs/research/opentui-screenshots/`), captured live
|
||||
on a real PTY 120×40 via the `tmux-pane-screenshot` workflow — **same session
|
||||
resumed in both engines** where possible.
|
||||
|
||||
### Legacy Ink — `docs/research/opentui-screenshots/ink-transcript.png`
|
||||
|
||||

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

|
||||
|
||||

|
||||
|
||||
- **Flat, marker-based layout.** No tree rails. Assistant = `⚕` (caduceus, gold
|
||||
only on the newest answer), user = `❯` (gold chevron + gold text). Older
|
||||
assistant glyphs demote to grey.
|
||||
- **Neutral body text.** Gold is reserved for markers and inline-code accents;
|
||||
prose is grey/white (the "single lamp" rule), so the screen reads calmer than
|
||||
Ink's all-amber blocks.
|
||||
- **Tool calls render inline, expanded, on one header line:**
|
||||
`⚕ ▶ delegate_task Run the shell command `…` (/agents to monitor) · 41s (11 lines)`
|
||||
— marker, `▶` collapse triangle, bold tool name, grey arg preview, hint,
|
||||
`· duration`, `(N lines)` — and the result flows flat directly below (no nesting
|
||||
rail). Per-tool renderers exist (`view/tools/registry.tsx`) — bash/file+diff/
|
||||
read/search/skill/clarify/todo each render differently, not a uniform dump.
|
||||
- **Per-block `⧉ copy` affordance** on a quiet footer line under every settled
|
||||
assistant block and user prompt (click → copies that block's source).
|
||||
- **Top header bar:** `⚕ Hermes Agent · opentui · ready` + a gold horizontal rule
|
||||
(Ink has none).
|
||||
- Status bar (real backend): `● claude-fable-5 │ [▒▒▒] 4% │ …/lively-thrush/hermes-agent (feat/opentui-native-engine)`
|
||||
— green status dot, model, context/token bar, **right-pinned cwd + branch**.
|
||||
|
||||
### Divergence summary table
|
||||
|
||||
| Aspect | Ink (legacy) | OpenTUI (new) |
|
||||
|---|---|---|
|
||||
| Transcript structure | Box-drawing **tree** (`└─`, rails) | **Flat**, indented, marker-based |
|
||||
| Assistant marker | `└─ Response` rail + `┊` quote-bar | `⚕` caduceus glyph |
|
||||
| User marker | (rail) | `❯` gold chevron |
|
||||
| Assistant body color | Tinted gold/amber | Neutral grey/white (gold = accents only) |
|
||||
| Tool calls | Collapsed `▾ Tool calls (N)`, nested | Inline expanded header + flat result |
|
||||
| Per-tool rendering | Largely uniform | Dedicated renderers per tool |
|
||||
| Copy affordance | `/copy` command | `/copy` **+ per-block `⧉ copy`** |
|
||||
| Header bar | None | `⚕ Hermes Agent · opentui · ready` + rule |
|
||||
| Status bar | `─`/`│`-delimited, trailing `~` | dot + bars + right-pinned cwd/branch |
|
||||
|
||||
**For review:** the divergence is intentional (a design pass, not an accident),
|
||||
but it means "drop-in replacement" is true at the *feature* level, not the
|
||||
*pixel* level. A user switching engines will immediately notice the flatter,
|
||||
calmer transcript. Worth calling out explicitly so the swap isn't sold as
|
||||
visually invisible.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-core / kitchen-sink change audit (what review should scrutinize)
|
||||
|
||||
Full report: **`docs/research/opentui-noncore-change-audit.md`** (file-by-file,
|
||||
commit-by-commit, with `file:line` evidence). Summary below.
|
||||
|
||||
This PR's net footprint vs `origin/main` (two-dot diff = exactly this PR's adds,
|
||||
no main work re-included):
|
||||
|
||||
| Bucket | Files | Net diff |
|
||||
|---|---:|---:|
|
||||
| UI (`ui-opentui/`, the engine + tests) | 197 | +36,001 / −1 |
|
||||
| Docs | 8 | +1,164 / −0 |
|
||||
| **Other (the review-flag surface)** | **28** | **+3,218 / −204** |
|
||||
|
||||
The 28 "other" files are the only place this PR touches shared Hermes core. They
|
||||
classify as:
|
||||
|
||||
### ✅ CORE-OPENTUI-NECESSARY (the engine can't work without these; Ink path provably untouched)
|
||||
|
||||
- **`hermes_cli/main.py`** (+382/−5) — dual-engine launcher (engine resolution,
|
||||
Node 26 / fnm detection, `_make_opentui_argv`, heap override). Default falls
|
||||
back to Ink unless the host is OpenTUI-ready (`main.py:1685`); OpenTUI is
|
||||
dispatched *around* the Ink bootstrap, never through it (`main.py:1914-1922`).
|
||||
- **`scripts/install.sh`** (+78/−1) — `install_opentui` stage, **strictly
|
||||
best-effort** (every failure returns 0; falls back to Ink; Windows/Termux
|
||||
skipped). Ink install path unchanged.
|
||||
- **`Dockerfile`** (+21/−11) — Node 22→**26** bump (required by the `node:ffi`
|
||||
renderer) + `ui-opentui` build step. Opt-in; Ink build line preserved. **Caveat:
|
||||
the Node major bump affects the whole image (Ink + web + Playwright)** — the
|
||||
diff self-flags "verify the full image build on Node 26 in CI."
|
||||
- **`hermes_cli/_parser.py`** (+16/−2) — bare `--resume` → OpenTUI session picker;
|
||||
`--resume <id>` unchanged.
|
||||
- **`tui_gateway/server.py`** (+612/−40) — predominantly opt-in RPCs/fields the
|
||||
new engine calls (`session.peek`, `session.list` filters, `startup.catalog`,
|
||||
`diff_unified`, window-title, skin keys). Each is gated so **the Ink path is
|
||||
byte-for-byte unchanged** (`server.py:3930`, `:4254`, `:10447`). *Note:* this
|
||||
file also carries some of the cost-accounting code (below) — separable.
|
||||
|
||||
> `tui_gateway/` (~12k LOC Python) is **shared by both engines** and is **not**
|
||||
> removed when Ink is retired. Only the `ui-tui/` frontend tree goes.
|
||||
|
||||
### 🚩 FLAG FOR REVIEW — Category C, separable from an OpenTUI PR
|
||||
|
||||
These do **not** need to ship with the engine and a reviewer should ask to split
|
||||
them out:
|
||||
|
||||
1. **Provider-reported-cost accounting** (commits `85546bb9e` + `364b93a4b` +
|
||||
`e01b04de4`) — a coherent feature spanning **11 files**: `agent/usage_pricing.py`,
|
||||
`plugins/model-providers/openrouter/__init__.py`,
|
||||
`agent/transports/chat_completions.py`, `agent/agent_init.py`, `run_agent.py`,
|
||||
`agent/conversation_loop.py`, `agent/account_usage.py`, `hermes_state.py`,
|
||||
`gateway/slash_commands.py`, the cost half of `cli.py`, and the
|
||||
`_get_usage`/`_compact_usage_text` blocks of `tui_gateway/server.py` (+ 5 test
|
||||
files). Strongest evidence: commit `85546bb9e` *"gateway: capture real
|
||||
provider-reported cost (openrouter usage accounting)"* — a provider-accounting
|
||||
rework, not a renderer.
|
||||
2. **`plugins/model-providers/openrouter/__init__.py`** — sends
|
||||
`usage:{include:true}`, a provider request-shape change affecting *all*
|
||||
interfaces, not just the TUI (`openrouter/__init__.py:85-90` cites the
|
||||
OpenRouter usage-accounting docs).
|
||||
3. **Worktree lock / dirty-tree preservation** (commit `94765e48f`,
|
||||
`cli.py` + `tests/cli/test_worktree.py`, ~145 lines) — git-worktree lifecycle
|
||||
safety plumbing with **zero TUI references** (`cli.py:1391-1545`, `:1635-1713`).
|
||||
4. **`tools/clarify_tool.py`** (+16/−4) — docstring/schema-description-only fix
|
||||
(commit `16e408f3f`); applies to every interface, trivially separable.
|
||||
|
||||
### ✅ Conversation-loop / role-alternation / prompt-cache correctness verdict: **NO RISK**
|
||||
|
||||
Verified: none of `run_agent.py`, `agent/conversation_loop.py`,
|
||||
`agent/agent_init.py`, `agent/transports/chat_completions.py` touch
|
||||
message-role alternation or the prompt-cache prefix. The
|
||||
`conversation_loop.py` added lines grep clean for
|
||||
`cache_control|alternation|prompt_cach|api_messages`; the cache/alternation
|
||||
machinery (`:57`, `:660-674`, `:759`) is untouched; the PR's insertion at
|
||||
`:1809-1879` is purely additive cost bookkeeping after `cost_result`. **Prompt
|
||||
caching and strict role alternation are preserved.**
|
||||
|
||||
---
|
||||
|
||||
## 5. What this does and does NOT fix
|
||||
|
||||
**Fixes (structurally, by replacing the rendering substrate):** the renderer bug
|
||||
class — layout/scroll/input/copy/mouse/markdown/resize — plus the
|
||||
hand-maintained memory-eviction problem (windowing + Solid keyed `<For>`
|
||||
unmount→`destroy()`→`free()`), and several long-open feature requests (mouse,
|
||||
collapsible tool calls, session title/status bar, double-ESC, chronological
|
||||
thinking/tool ordering).
|
||||
|
||||
**Does NOT fix:** the gateway is unchanged — the biggest single hotspot file in
|
||||
triage is `tui_gateway/server.py`, and whole bug clusters are gateway/Python-side
|
||||
(WS write-timeout/RPC pool, MCP-failure startup freezes, shell.exec denylist).
|
||||
The engine swap addresses rendering/input/scroll/memory; **gateway bugs ride
|
||||
along.** The Effect-boundary hardening does make those failures *visible* (typed
|
||||
events → system lines instead of a frozen spinner) and the TUI auto-heals
|
||||
(crash → backoff → respawn → resume, capped 3/60s).
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature parity matrix (vs the Ink TUI)
|
||||
|
||||
Verbatim, detailed, surface-by-surface with `file:line` evidence:
|
||||
**`docs/plans/opentui-ink-parity-matrix.md`** (interactive/filterable version in
|
||||
the HTML write-up). Headline state:
|
||||
|
||||
| Surface | State |
|
||||
|---|---|
|
||||
| Transcript rendering (scrollbox, markdown, code, diffs, collapsible tools, reasoning, chronological order, windowing) | **full parity (9/9)** |
|
||||
| Blocking prompts (approval/clarify/sudo/secret/confirm) | **full parity (5/5)** |
|
||||
| Theming (skins, light/dark, ANSI-256 norm) | **full parity** |
|
||||
| Mouse / copy (tracking, selection, multi-click, OSC52, click-to-expand, wheel accel) | **full parity** |
|
||||
| Resilience (crash auto-heal + resume) | **parity++ (exponential backoff)** |
|
||||
| Composer / input | near parity — **missing: external editor (Ctrl+G → `$EDITOR`)**; ghost-text autosuggest partial |
|
||||
| Slash commands | core parity — **missing: `/setup`, `/redraw`, `/plugins`, `/voice`**; `/undo` prefill + `/image` partial |
|
||||
| Status bar / header chrome | almost all closed — **missing: MCP-servers panel, profile-in-prompt** |
|
||||
| Agent surfaces | most shipped — **missing: voice indicators, browser/CDP indicator** |
|
||||
| Utility commands | **missing: `/redraw`, `/setup`**; rest present |
|
||||
|
||||
> The original PR-draft gap list was **substantially stale** — the WIP since
|
||||
> shipped context %/token bar, cost, compressions, duration, update banner, todos
|
||||
> panel, activity feed, notifications, background-task indicator, **and per-tool
|
||||
> renderers** (the "every tool renders the same" claim is false:
|
||||
> `view/tools/registry.tsx` has dedicated renderers).
|
||||
|
||||
### Genuinely-remaining parity gaps
|
||||
|
||||
- [ ] **External editor (Ctrl+G → `$EDITOR`)** — highest-impact missing composer affordance
|
||||
- [ ] MCP-servers detail panel; profile-in-prompt marker
|
||||
- [ ] Voice indicators (listening/transcribing/REC/STT) + `/voice`
|
||||
- [ ] Browser/CDP connection indicator + `/browser`
|
||||
- [ ] `/setup` wizard handoff, `/redraw`, `/plugins` hub
|
||||
- [ ] Draggable scrollbar; sticky-prompt line
|
||||
- [ ] `/undo` prefill into composer; model-picker persist-global toggle; skills-hub install/manage
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout, runtime & risks
|
||||
|
||||
- **Runtime:** plain Node 26 (FFI floor 26.3+) — one runtime, no Bun. (Note: the
|
||||
upstream OpenTUI docs say "requires Bun"; this engine deliberately runs on Node
|
||||
26's experimental `node:ffi` instead — that's the load-bearing runtime decision.)
|
||||
- **Rollback:** Ink is untouched and remains the fallback; reverting is a launcher
|
||||
decision, not a code revert.
|
||||
- **Default-engine selection:** auto-picks OpenTUI only when the host is genuinely
|
||||
set up (Node ≥ 26.3 + built bundle), else Ink; explicit env/config bypasses the
|
||||
probe.
|
||||
- **Known sharp edges:** `libopentui.so` native-lib distribution (P1 upstream:
|
||||
copies can fill `/tmp`); the Dockerfile Node major bump needs full-image CI
|
||||
verification; tree-sitter grammars are fetched from GitHub on first use and
|
||||
cached in `~/.hermes/cache/opentui-parsers/` — air-gapped hosts get plain-text
|
||||
highlighting until the cache is pre-seeded (the fetch never blocks boot and
|
||||
never throws).
|
||||
|
||||
## 8. Try it
|
||||
|
||||
```bash
|
||||
hermes # auto-selects OpenTUI when the host supports it
|
||||
HERMES_TUI_ENGINE=opentui hermes # force the native engine
|
||||
HERMES_TUI_ENGINE=ink hermes # force the legacy Ink engine
|
||||
# preview standalone (no backend), Node 26:
|
||||
cd ui-opentui && npm install
|
||||
node scripts/build.mjs scripts/demo.tsx .demo
|
||||
DEMO_TOTAL=120 HERMES_TUI_MAX_MESSAGES=80 \
|
||||
node --experimental-ffi --no-warnings .demo/demo.js # inside a TTY
|
||||
```
|
||||
|
||||
Requires Node 26.3+. On older Node / Windows / Termux it auto-falls-back to Ink.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — source-of-truth files in this repo
|
||||
|
||||
| Topic | File |
|
||||
|---|---|
|
||||
| Non-core change audit (full) | `docs/research/opentui-noncore-change-audit.md` |
|
||||
| Feature parity matrix (verbatim) | `docs/plans/opentui-ink-parity-matrix.md` |
|
||||
| Benchmark report | `docs/plans/opentui-endgame-benchmark-report.md` |
|
||||
| Gut-check verification | `docs/plans/opentui-gutcheck-verification.md` |
|
||||
| Ink↔OpenTUI capture asymmetry | `docs/plans/opentui-ink-asymmetry-note.md` |
|
||||
| UI screenshots | `docs/research/opentui-screenshots/{ink,opentui}-*.png` |
|
||||
| PR description (prose) | `docs/pr-description-main-doc.md` |
|
||||
| Interactive write-up | `~/projects/opentui-perf-writeup/index.html` (out-of-repo) |
|
||||
@@ -0,0 +1,73 @@
|
||||
# Upstream alignment — how we inherit OpenTUI's performance work for free
|
||||
|
||||
Context (maintainer, 2026-06-11): opencode's 100-message cap was a November-era
|
||||
performance workaround, since obsoleted; the **next OpenTUI version ships
|
||||
native yoga** (≥2× layout performance, more improvements building on it);
|
||||
opencode does not use virtualization.
|
||||
|
||||
## The invariant that makes alignment free
|
||||
|
||||
**We are forkless and public-API-only.** The windowing layer (S1+S2) drives the
|
||||
STOCK `<scrollbox>` through documented surface only — `onSizeChange`,
|
||||
`setFrameCallback`, `scrollTop`/`viewport`/`scrollHeight`, Solid `<Show>`
|
||||
mount/unmount. Zero patches to `@opentui/core`. Every upstream release
|
||||
therefore drops in by bumping three pinned versions in `ui-opentui/package.json`
|
||||
(`@opentui/{core,keymap,solid}`, currently 0.4.0). Keep it that way: any new
|
||||
code that needs core behavior goes through a `boundary/` wrapper, never a
|
||||
patched dependency.
|
||||
|
||||
## What native yoga changes for us (and what it doesn't)
|
||||
|
||||
- **Kills the WASM ratchet** (grow-only linear memory → freeable native
|
||||
allocations). This retro-justifies S2 less, but S2's append-time windowing
|
||||
remains correct: transient mounted peaks still cost handles and RSS.
|
||||
- **Does NOT obsolete windowing.** The binding constraint is the 65,535-slot
|
||||
native handle table: ~47 handles/row × 3,000 stored rows ≈ 141k handles —
|
||||
over the table at ANY layout speed. Windowing is what makes the 3,000-row
|
||||
scrollback possible; yoga's backend is irrelevant to that math.
|
||||
- **Makes windowing feel even better**: 2× layout = cheaper margin remounts =
|
||||
smaller window margins viable and less exposure for the one accepted limit
|
||||
(estimate-height snap under scrollbar jumps). After the bump, re-tune margin/
|
||||
hysteresis against the scroll cell.
|
||||
|
||||
## The shim ledger (delete-on-upstream-fix; all in `ui-opentui/src/boundary/`)
|
||||
|
||||
| shim | what it papers over | delete when |
|
||||
|---|---|---|
|
||||
| `ffiSafe.ts` | u32 draw coords go negative under Node FFI (Bun silently wraps) — ERR_INVALID_ARG_VALUE loop | upstream clamps, or Node FFI path is officially supported |
|
||||
| `nativeHandles.ts` | SyntaxStyle exhaustion crashes mid-mount; degrade-to-unstyled | handle table widened (INDEX_BITS>16) or per-kind tables |
|
||||
| `renderer.ts` exit-signal guard | core 0.4.0 treats SIGPIPE (clipboard spawn) as an exit signal; its own uncaughtException handler allocates a handle and dies (exit-7 masking) | both fixed upstream |
|
||||
| `clipboard.ts` hardening | same SIGPIPE incident class | with the above |
|
||||
|
||||
Each is (a) isolated, (b) inert if upstream fixes the behavior, (c) worth
|
||||
reporting upstream — four concrete, reproduced, root-caused issues. Filing them
|
||||
is the cheapest alignment lever we have: it converts our workarounds into
|
||||
upstream regression tests. (Needs glitch's go-ahead — public repo activity.)
|
||||
|
||||
## The upgrade playbook (per upstream release)
|
||||
|
||||
1. Branch `chore/opentui-X.Y.Z`, bump the three pins, `npm ci`.
|
||||
2. `npm run check` (648 tests; the windowing invariants — identical
|
||||
scrollHeight ON/OFF, byte-stable frames across corrections — are literal
|
||||
assertions and will catch behavioral drift).
|
||||
3. Bench acceptance, sequential: `--cell gate` (determinism digest; EXPECT a
|
||||
new digest if upstream changed rendering — eyeball the frame, re-bless),
|
||||
`--cell mem3000 --msgs 2000` + `--cell scroll --msgs 3000` vs current
|
||||
numbers (300–375MB / p99 6–8ms), `--cell pipeline` (frame pacing ≥22fps).
|
||||
4. Shim audit: try each boundary shim OFF; delete the ones upstream fixed.
|
||||
5. Live tmux smoke (scroll sweep / resize / selection-copy), screenshots.
|
||||
6. Windowing re-tune if layout got faster: margins up or hysteresis down,
|
||||
re-run scroll cell, keep p99 ≤ 17ms gate.
|
||||
|
||||
The bench suite IS the upgrade contract — it's exactly the harness that lets
|
||||
us take every upstream improvement within a day of release, with proof.
|
||||
|
||||
## Questions worth relaying to the maintainer
|
||||
|
||||
1. Any plan to widen the 16-bit native handle table (or split per-kind)?
|
||||
That's our hard ceiling, independent of yoga.
|
||||
2. Is the Node `--experimental-ffi` path on their support radar, or Bun-only?
|
||||
(Native yoga adds new FFI surface; we run Node.)
|
||||
3. Would they take the windowing layer's core-agnostic pieces (exact-height
|
||||
spacer pattern, correction-legality rule) as a documented recipe or
|
||||
framework-level utility? We have it production-shaped with tests.
|
||||
@@ -0,0 +1,150 @@
|
||||
# OpenTUI — Background Activity: agents inspection, background panel, notifications + density
|
||||
|
||||
**Status:** SPEC (brainstormed with glitch 2026-06-13) · target branch `feat/opentui-native-engine`
|
||||
**Hard constraint:** TUI-LAYER ONLY (`ui-opentui/`). **Zero changes to `tui_gateway/server.py` or
|
||||
`run_agent.py` core.** Build only on gateway events/RPCs that already exist. Everything below was
|
||||
feasibility-checked against the live gateway surface (see "Gateway surface" §).
|
||||
|
||||
## Why
|
||||
|
||||
Dogfeedback (screenshots `iznq/qxpe/rpiw/rplj`):
|
||||
1. **Agents dashboard is too crowded** (`rplj`) — master rows dump each subagent's full multi-line
|
||||
prompt; the trace pane is squished. Inspection + transcript reading is "not great."
|
||||
2. **Background processes are basically invisible** (`qxpe`) — completions leak into the transcript
|
||||
as plain lines that read like model output; no panel, no badge, notifications are non-existent.
|
||||
3. **Input zone is too crowded** (`rpiw`) — status bar + composer + agents tray + completion menu +
|
||||
shell note stack under the transcript.
|
||||
|
||||
## Design decisions (from the brainstorm)
|
||||
|
||||
- **Two SEPARATE surfaces, ONE shared substrate.** Background *agents* (delegated subagents) and
|
||||
background *work* (detached runs + OS processes) are visually/feature-wise distinct, but share the
|
||||
underlying tracking + notification + badge plumbing.
|
||||
- **Notifications are multi-channel** on every relevant state change:
|
||||
- **(C) inline card** in the transcript — a distinct, colored, collapsed *system card*, clearly
|
||||
NOT model output (replaces today's plain-line leak).
|
||||
- **(A) ambient badge** — a live count in chrome (status-bar `bg:`/the `⚡ N agents` tray) that
|
||||
flashes on change; you pull-to-inspect. Stays visible while things run.
|
||||
- **OSC desktop** — reuse the EXISTING `boundary/termChrome.ts` (`notify`, OSC 9/99/777, already
|
||||
focus-gated so it only fires when the terminal is blurred).
|
||||
- **Agents surface = inspection only.** No foregrounding / "become the subagent" (that would change
|
||||
core subagent UX — explicitly out of scope). Scannable list + a faithful render of the *already-
|
||||
tracked* live activity (goal/model/reasoning/tool calls/progress/summary). No new fetch.
|
||||
- **Background surface = view + stop.** List runs + OS processes with status/uptime; cancel a run
|
||||
(`session.interrupt`/`subagent.interrupt`); **stop-all** OS processes (`process.stop`). Per-process
|
||||
kill and per-process logs are NOT exposed as RPCs → out of scope under the no-core rule (noted).
|
||||
- **Input density is in scope** (own phase).
|
||||
|
||||
## Gateway surface we build on (verified — all already exist)
|
||||
|
||||
| Need | Mechanism (existing) |
|
||||
|---|---|
|
||||
| Background-run lifecycle | `prompt.background` (start), `background.complete` (event) |
|
||||
| Notifications | `notification.show` / `notification.clear` events — payload `{text, level, kind, ttl_ms, key, id}` |
|
||||
| Subagent stream | `subagent.spawn_requested/start/thinking/tool/progress/complete` events (store already consumes) |
|
||||
| List OS processes | `agents.list` RPC → `{processes:[{session_id, command, status, uptime_seconds}]}` |
|
||||
| Stop OS processes | `process.stop` RPC → `kill_all()` (**all**, not per-process) |
|
||||
| Cancel a run / subagent | `session.interrupt`, `subagent.interrupt` |
|
||||
| List active sessions/runs | `session.active_list`, `session.status` |
|
||||
| Subagent trace (archived) | `spawn_tree.list/load` (already used by `/replay`) |
|
||||
| OSC desktop notify | `boundary/termChrome.ts` `notify(TermNotification)` |
|
||||
|
||||
**Honest limits (no-core constraint):** OS processes get list + stop-all only — no per-process kill
|
||||
(`process_registry.kill_process` exists but isn't an RPC) and no per-process log tail
|
||||
(`read_log` isn't an RPC). If the no-core rule is ever relaxed, each is a ~5-line additive `@method`.
|
||||
|
||||
## Architecture (Approach 1 — substrate-first)
|
||||
|
||||
```
|
||||
gateway events ──► store: backgroundActivity slice ──► derived counts/state
|
||||
│ │
|
||||
├─► notificationDispatcher ─────────┼─► (C) inline card (transcript)
|
||||
│ (card + badge + OSC) ├─► (A) ambient badge (statusBar/tray)
|
||||
│ └─► OSC via termChrome.notify
|
||||
├─► Surface 1: AgentsDashboard (revamp) — list + rich activity pane
|
||||
└─► Surface 2: BackgroundPanel (new) — runs + processes, stop
|
||||
```
|
||||
|
||||
### Shared substrate (the "underneath" both surfaces use)
|
||||
|
||||
- **`logic/backgroundActivity.ts`** (new) — pure model + reducers. Types:
|
||||
- `BackgroundRun` (from `prompt.background`/`background.complete`/`session.active_list`):
|
||||
`{ id, label, status: 'running'|'complete'|'failed'|'cancelled', startedAt, summary? }`
|
||||
- `BackgroundProcess` (from `agents.list`): `{ sessionId, command, status, uptimeSeconds }`
|
||||
- `Notification` (from `notification.show`): `{ id, key?, text, level, kind, ttlMs?, at }`
|
||||
- Pure helpers: `applyNotification`, `clearNotification(key)`, counts (`runningCount`),
|
||||
`mergeProcessList`, dedupe by `key`/`id`. Fully unit-testable (no renderer).
|
||||
- **`store.ts`** — a `backgroundActivity` slice + event handlers for `notification.show/clear`,
|
||||
`background.complete`, and a polled `agents.list` snapshot (poll only while a panel/badge is live,
|
||||
or piggyback existing cadence). Existing `subagent.*` handling is untouched.
|
||||
- **`logic/notificationDispatcher.ts`** (new, pure) — given a state-change, decide the channels:
|
||||
returns `{ card?: SystemCard, badge: delta, osc?: TermNotification }`. The boundary calls
|
||||
`termChrome.notify` for the OSC part; the store appends the card + bumps the badge.
|
||||
|
||||
### Surface 1 — Agents inspection overlay (revamp `view/overlays/agentsDashboard.tsx`)
|
||||
|
||||
- **Master list rows = ONE line each:** `<statusGlyph> <truncated goal (truncRight to width)> · <model>`.
|
||||
No multi-line prompt dump. Selected row highlighted (existing `▸` + accent).
|
||||
- **Detail pane = faithful activity transcript** of the selected agent, styled like the main
|
||||
transcript (not flat dumped lines): goal+model header, then the trace rendered by *type*
|
||||
(reasoning / tool-call+result / progress / final summary), newest last, sticky-bottom, PgUp/PgDn.
|
||||
- Requires giving `SubagentInfo.trace` light typing (`{ kind:'tool'|'reasoning'|'progress'|'summary', text }`)
|
||||
instead of `string[]`, populated where `subagent.*` events are reduced. Internal data-shape
|
||||
change only; no gateway change.
|
||||
- Keep Esc/q close, ↑↓ select. Reuse theme + `truncRight` from statusBar.
|
||||
|
||||
### Surface 2 — Background panel (new `view/overlays/backgroundPanel.tsx`)
|
||||
|
||||
- **Two sections:** *Runs* (background agent runs) and *Processes* (OS processes from `agents.list`).
|
||||
- Each row: status glyph + label/command (truncated) + uptime/elapsed + status.
|
||||
- **Actions:** `↑↓` select; on a *run* → `c` cancel (`session.interrupt`/`subagent.interrupt`);
|
||||
global **stop-all processes** (`x` → `process.stop`, confirm). Esc/q close.
|
||||
- **Access:** new client slash `/bg` (alias `/background`, `/jobs`) in `logic/slash.ts` CLIENT set →
|
||||
`store.openBackgroundPanel()`. Also reachable from the ambient badge.
|
||||
- Poll `agents.list` on open + on a light interval while open; stop polling on close.
|
||||
|
||||
### Notifications (the (C)+(A)+OSC wiring)
|
||||
|
||||
- **(C) inline card** — a new transcript element `view/notificationCard.tsx`: a bordered/colored,
|
||||
`selectable:false` system card keyed by `notification.id`, level-tinted (`info/warn/error`),
|
||||
collapsed to one line by default with the `kind` + `text`; clearable by `notification.clear` key.
|
||||
Appended into the message stream as a distinct row type (NOT a plain `system` text line). Replaces
|
||||
the current plain-line leak. (`/details` interplay: cards are chrome, always shown, never windowed.)
|
||||
- **(A) ambient badge** — `statusBar.tsx` `bg: N` segment (already reserved) bound to
|
||||
`runningCount()`; the `agentsTray.tsx` count already exists — extend it to "agents + background."
|
||||
Flash/recolor on a fresh notification (brief).
|
||||
- **OSC** — on `notification.show` with a terminal level (complete/failed), call
|
||||
`termChrome.notify({title, body})` (already focus-gated). No new escape-sequence code.
|
||||
|
||||
### Input-zone density pass (`view/composer.tsx` / `view/App.tsx`)
|
||||
|
||||
- Audit what stacks under the transcript and collapse/gate: the `⚡ N agents` tray line folds into
|
||||
the ambient badge (shrinks one line); ensure the shell-mode note, completion menu, and status bar
|
||||
don't co-stack more than necessary. Concrete rules decided with a tmux density pass (ASCII-mocked,
|
||||
approved) — kept minimal; no behavior change, just fewer competing chrome lines.
|
||||
|
||||
## Phases (implementation order — each gated + tmux-smoked + committed)
|
||||
|
||||
- **P1 — Notification substrate** (`backgroundActivity.ts` + `notificationDispatcher.ts` + store
|
||||
slice + `notificationCard.tsx` + badge wiring + OSC call). Highest visible win; the shared core.
|
||||
- **P2 — Agents inspection revamp** (`agentsDashboard.tsx` + typed `trace`). De-crowds `rplj`.
|
||||
- **P3 — Background panel** (`backgroundPanel.tsx` + `/bg` + actions). New surface.
|
||||
- **P4 — Input density pass.** Folds the tray into the badge; trims co-stacked chrome.
|
||||
|
||||
## Testing / gates (per phase)
|
||||
|
||||
- **Pure logic** (`backgroundActivity`, `notificationDispatcher`, slash `/bg` routing,
|
||||
trace-typing) → vitest unit tests, TDD where natural.
|
||||
- **Views** → headless frame tests (`renderProbe`) for the card, the de-crowded dashboard row
|
||||
format, the background panel sections; + **live tmux smoke** (`tmux-pane-screenshot`) for each
|
||||
surface using a seeded-store harness (the `uxSmoke` pattern: `store.apply`/`applyInfo`/
|
||||
`commitSnapshot` + canned events).
|
||||
- **Gate** `cd ui-opentui && npm run check` green (judge by real exit, not a piped tail) after each
|
||||
phase; rebuild `dist/main.js`; commit `opentui(v6): …` (no attribution) and push per standing instr.
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- Foregrounding / "becoming" a subagent (B/C from the brainstorm) — would change core subagent UX.
|
||||
- Per-process kill + per-process log tail for OS processes — needs additive gateway RPCs (no-core veto).
|
||||
- "Collect result into transcript" for finished runs — deferred (Q6=B, view+stop only).
|
||||
- Any change to `tui_gateway/server.py` / `run_agent.py`.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Plan — OpenTUI composer/UX batch (10 features)
|
||||
|
||||
> **STATUS: SHIPPED (2026-06-13).** All 10 features implemented, gate green
|
||||
> (ui-opentui 714 tests + 316 gateway + 25 cost tests), F5/F6 verified live via
|
||||
> tmux screenshot. Commits: `f4dacc68e` (F1/F2/F7/F8/F8b/F9/F10), `20d516ae9`
|
||||
> (F4/F5/F6), `9aa5e54be` (F3). Decisions taken: **D1 = cursor-aware onType**
|
||||
> (threaded `ta.cursorOffset`); **D2 = chrome cost is Nous-header-only via a new
|
||||
> `nous_header_cost_usd`, `/usage` page kept full via `real_session_cost_usd`**.
|
||||
> F10 (right-pinned cwd) was added mid-session by the user.
|
||||
|
||||
**Branch:** `feat/opentui-native-engine` · **Engine:** `ui-opentui/` (Node 26)
|
||||
**Gate:** `cd ui-opentui && PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH" npm run check` → exit 0.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Nine UX fixes for the native composer + clarify prompt. **8 of 9 are front-end-only**
|
||||
in `ui-opentui/`; only F3 (cost) touches the Python gateway. Every backend the new
|
||||
behaviour needs (`shell.exec`, `complete.path` with `@file:`/`@folder:`/fuzzy) **already
|
||||
exists** — most of this is client wiring, not new RPC surface. No new core tools, no new
|
||||
`HERMES_*` env vars, no prompt-cache impact (composer/prompt are client-render only).
|
||||
|
||||
| # | Symptom | Fix site | Backend |
|
||||
|---|---|---|---|
|
||||
| F1 | bare `/` opens the modal | `logic/slash.ts:115` `planCompletion` | none |
|
||||
| F2 | `/abs/path` text triggers slash | `logic/slash.ts:115` + `logic/skillMatch.ts` | none |
|
||||
| F3 | cost wrong / shows for non-Nous | `tui_gateway/server.py` + `agent/usage_pricing.py` | gateway |
|
||||
| F4 | can't paste until composer focused | `view/composer.tsx` onPaste/focus | none |
|
||||
| F5 | clarify ugly (no wrap, weak diff, "Other" is a row) | `view/prompts/clarifyPrompt.tsx` rewrite | none |
|
||||
| F6 | clarify arrows scroll the transcript | same rewrite (preventDefault) | none |
|
||||
| F7 | slash highlight/menu dies after line 1 | `logic/slash.ts:114` | none |
|
||||
| F8 | file mention dies after line 1 | `logic/slash.ts:114` | none |
|
||||
| F8b | `@` should be the ONLY file-mention trigger | `logic/slash.ts:93` `isPathLike` | none |
|
||||
| F9 | `!cmd` → run bash, show result | `entry/main.tsx` submit + new system render | uses existing `shell.exec` |
|
||||
|
||||
---
|
||||
|
||||
## F1 + F2 + F7 + F8 + F8b — the completion trigger (`logic/slash.ts`)
|
||||
|
||||
All five live in one ~10-line function, `planCompletion` (slash.ts:113-121). Current:
|
||||
|
||||
```ts
|
||||
export function planCompletion(text: string): CompletionPlan | null {
|
||||
if (text.includes('\n')) return null // ← F7/F8 die here
|
||||
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } } // ← F1/F2
|
||||
const word = /(\S+)$/.exec(text)?.[1]
|
||||
if (word && isPathLike(word)) { ... complete.path ... } // ← F8b: too many triggers
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### F1/F2 — slash only for a real command token
|
||||
- A bare `/` (no char yet) must **not** query. Require `/` + at least one name char.
|
||||
- A `/abs/path` (slash followed by a path with more `/`) is **not** a command — it's
|
||||
text. The slash menu should only fire when the FIRST token matches the command
|
||||
grammar (`/[A-Za-z0-9][\w.-]*` — the `NAME_RE` already in skillMatch.ts:51, which
|
||||
excludes `/`). `/usr/bin` fails NAME_RE → no slash menu.
|
||||
- Concretely: replace `text.startsWith('/')` with: the text starts with `/`, and the
|
||||
first whitespace-delimited token after the `/` is non-empty AND matches `NAME_RE`
|
||||
(i.e. `/m`, `/model foo` → yes; `/`, `/usr/bin`, `/./x` → no). Reuse `slashTokens`
|
||||
/`NAME_RE` from skillMatch.ts so the trigger and the highlighter share one grammar.
|
||||
|
||||
### F7/F8 — completion must survive newlines (shift+enter)
|
||||
- `if (text.includes('\n')) return null` is the bug. It was a blunt guard so a multi-line
|
||||
paste wouldn't spam path-completion. The right rule operates on the **current line /
|
||||
current token at the cursor**, not the whole buffer.
|
||||
- The composer passes the full `plainText` to `onType`. We don't currently pass the
|
||||
cursor offset. **Decision D1 (below):** either (a) thread the cursor offset into
|
||||
`onType` and complete the token under the cursor, or (b) cheap interim — slice to the
|
||||
**last line** (`text.slice(text.lastIndexOf('\n')+1)`) and run the existing logic on
|
||||
that. (a) is correct (mid-buffer edits), (b) is 1 line and covers the reported case
|
||||
(typing at the end on line N). Recommend (a) for correctness; it also future-proofs
|
||||
@-mention mid-line.
|
||||
- Slash *highlighting* (skillMatch.ts `slashTokens`) **already scans multi-line text
|
||||
correctly** (it iterates the whole string, newline-aware via `nativeCharOffset`). So
|
||||
F7's "highlighting stopped" is really the same `planCompletion` newline bail starving
|
||||
the menu; the highlight token itself still styles. Verify in the live smoke.
|
||||
|
||||
### F8b — `@` is the only mention trigger
|
||||
- `isPathLike` (slash.ts:93) currently returns true for `@`, `~`, `./`, `../`, `/`, or
|
||||
any word containing `/`. The user wants **`@`-only** (drop `~`/`./`/bare paths as
|
||||
mention triggers). Narrow it to `word.startsWith('@')`.
|
||||
- The gateway `complete.path` (server.py:8543) already special-cases `@` richly
|
||||
(`@file:`, `@folder:`, `@diff`, `@staged`, `@url:`, `@git:`, fuzzy basename search).
|
||||
Its `~`/`./` branches become dead trigger paths from this TUI — leave the gateway code
|
||||
(Ink still uses the path forms; it's shared) but stop emitting those queries from
|
||||
ui-opentui. **No gateway change.**
|
||||
- Net: typing `@` (even bare) opens the mention menu via the `@`-bare branch at
|
||||
server.py:8555. Picking splices `@file:rel/path` etc. (existing accept path,
|
||||
`completionFrom` honoured).
|
||||
|
||||
**Tests:** extend `test/slash.test.ts` — `planCompletion('/')` → null; `planCompletion('/usr/bin')`
|
||||
→ null; `planCompletion('/model')` → complete.slash; multi-line `"a\n/mod"` → complete.slash
|
||||
on the trailing token; `"~/foo"` / `"./x"` → null (no longer path-like); `"@foo"` → complete.path.
|
||||
Keep them as behaviour assertions, not snapshots.
|
||||
|
||||
---
|
||||
|
||||
## F3 — cost: Nous-portal headers only (`tui_gateway` + `agent/usage_pricing.py`)
|
||||
|
||||
**Current:** `_get_usage` (server.py:2157-2167) sets `cost_usd` from
|
||||
`real_session_cost_usd(agent)` (usage_pricing.py:887), which sums **two** provider-reported
|
||||
sources:
|
||||
1. `agent.session_actual_cost_usd` — OpenRouter `usage.cost` accumulator.
|
||||
2. `agent.get_credits_spent_micros()` — Nous `x-nous-credits-*` header delta.
|
||||
|
||||
The TUI already **hides** the cost segment when `cost_usd` is absent (statusBar.tsx:241-243,
|
||||
`costText` returns '' when `costUsd === undefined`) — so this is purely "which sources count."
|
||||
|
||||
**User's intent (F3):** cost should come **only from the Nous portal headers**; suppress it
|
||||
for every other route (cache-token pricing is unreliable across the model long tail).
|
||||
|
||||
**Change:** make the OpenRouter accumulator source conditional on the route being Nous, OR
|
||||
drop source #1 entirely so only the header delta (source #2) feeds `cost_usd`. Source #2 is
|
||||
intrinsically Nous-only (the header only exists on Nous-portal responses), so dropping #1
|
||||
achieves "Nous-header-only" with one edit.
|
||||
|
||||
> **DECISION D2 (needs glitch's confirm):** Drop OpenRouter's `session_actual_cost_usd`
|
||||
> source from `real_session_cost_usd`? Trade-off: OpenRouter's `usage.cost` is itself
|
||||
> *provider-reported* (the real charged number, not a Hermes estimate), so OR users lose an
|
||||
> accurate readout. But it removes the cache-token guesswork the user is worried about and
|
||||
> matches "only via the headers when using nous portal" literally.
|
||||
> **Recommended default (implementing unless told otherwise):** gate source #1 so it only
|
||||
> contributes when the active route is the Nous portal (base_url == nous inference api),
|
||||
> else it's dropped. This keeps the segment Nous-only AND avoids touching shared OR/CLI
|
||||
> behaviour for the `/usage` page. If even Nous-route OR-accumulator is unwanted, collapse
|
||||
> to header-only.
|
||||
|
||||
**Scope guard:** `real_session_cost_usd` is also consumed by `/usage` page rendering
|
||||
(server.py:2237) and DB usage totals. Prefer a NEW, status-bar-specific helper
|
||||
(e.g. `nous_header_cost_usd(agent)`) wired only into `_get_usage`'s `cost_usd`, leaving the
|
||||
`/usage` accounting page untouched — so we don't regress the full cost report. Confirm with
|
||||
the gate + a gateway unit test (`tui_gateway` tests) that a non-Nous session yields no
|
||||
`cost_usd`.
|
||||
|
||||
---
|
||||
|
||||
## F4 — paste while composer unfocused (`view/composer.tsx`)
|
||||
|
||||
**Current:** the global keyboard handler reclaims focus on a *printable keystroke*
|
||||
(`isPrintableKey`, composer.tsx:415-417). A **bracketed-paste event is not a keystroke** —
|
||||
it arrives at `onPaste` only if the textarea is focused, so an unfocused composer drops it;
|
||||
the user has to click/type first.
|
||||
|
||||
**Fix:** the renderer delivers paste through the focused renderable. Two options:
|
||||
- (a) Keep focus on the composer more aggressively (opencode keeps the prompt focused via a
|
||||
reactive effect). Risky — fights transcript scroll focus.
|
||||
- (b) **Recommended:** handle paste at the renderer/global level. Check whether OpenTUI
|
||||
exposes a global paste hook (`renderer.on('paste')` or a keyboard event with
|
||||
`key.name === 'paste'` / a paste event type). If a global paste signal exists, on paste:
|
||||
`ta.focus()` then route the bytes into the existing `onPaste` logic (image / placeholder /
|
||||
insert). **Must verify the API in the `opentui` skill before coding** (skill_view
|
||||
references/docs). If only the focused-renderable paste exists, fall back to (a) scoped:
|
||||
refocus the composer whenever no overlay/prompt is open and focus drifted (a
|
||||
`createEffect` watching focus + `store.state.prompt`/overlay state).
|
||||
|
||||
**Verify in live smoke** (tmux + tmux-pane-screenshot): scroll the transcript to drop focus,
|
||||
then paste — text must land without a prior click.
|
||||
|
||||
---
|
||||
|
||||
## F5 + F6 — clarify prompt rewrite (`view/prompts/clarifyPrompt.tsx`)
|
||||
|
||||
Screenshot `/tmp/screenshots/SCR-20260613-iznq.png` confirms: long options run off the right
|
||||
edge (no wrap), options differ only by `▶`/`—` glyphs (no numbers, weak), and "✎ Other…" is
|
||||
a `<select>` row that *switches* to an input on Enter rather than being an inline input.
|
||||
|
||||
**Current:** one native `<select>` over `[...choices, {Other}]` (clarifyPrompt.tsx:61-75).
|
||||
Native `<select>` doesn't wrap long rows and (F6) doesn't `preventDefault` arrows, so they
|
||||
leak to the transcript scrollbox.
|
||||
|
||||
**Rewrite plan (verify renderable API in `opentui` skill first):**
|
||||
- Replace native `<select>` with a **custom keyboard-driven list** (a `For` over options +
|
||||
a `selected` signal + `useKeyboard` with `key.preventDefault()` on up/down/enter — same
|
||||
pattern the composer's `routeMenuKey` uses; F6 fixed by preventDefault so arrows never
|
||||
reach the scrollbox).
|
||||
- **Wrapping (F5):** render each option as a `<text>` that wraps to the box width (no fixed
|
||||
single-line). Indent continuation lines under the option label. Confirm `<text>` soft-wrap
|
||||
behaviour in the opentui skill (it wraps by default within a flex box of bounded width).
|
||||
- **Differentiation (F5):** number every option `1.` `2.` … (digit hotkeys optional, nice-to-
|
||||
have), and give the selected row the themed `selectionBg` + accent fg (the composer's
|
||||
`completionCurrentBg` model), not just a glyph. Number + background + accent = three signals.
|
||||
- **Inline custom answer (F5):** render the `<input>` **inside the same screen, always
|
||||
present** as the last "row" (an `Other:` labeled input), instead of an item that toggles.
|
||||
Selecting/focusing it lets the user type; Enter in it submits the free text. Keep the
|
||||
existing `clarify.respond {answer}` wiring. Arrow-down past the last choice lands on the
|
||||
input; arrow-up from the input returns to the list (focus handoff like the composer↔tray).
|
||||
- Keep Esc/Ctrl+C → cancel (clarifyPrompt.tsx:31-33).
|
||||
|
||||
**Reference:** opencode's selection/list components in `~/github/opencode/packages/tui` for
|
||||
the wrap + highlight + hotkey idiom; the composer dropdown (composer.tsx:441-458) for the
|
||||
in-repo highlight/selectable pattern.
|
||||
|
||||
**Tests:** `test/render.test.tsx`-style headless frame — long option wraps (frame contains the
|
||||
tail of a long choice on a 2nd line), selected row shows numbered + highlighted, custom input
|
||||
present in the same frame, arrow keys don't change scrollTop (assert transcript scroll
|
||||
unchanged), Enter on a choice → onAnswer(choice), Enter in input → onAnswer(typed).
|
||||
|
||||
---
|
||||
|
||||
## F9 — `!cmd` runs bash (`entry/main.tsx` + a system render)
|
||||
|
||||
**Backend exists:** `shell.exec` (server.py:10301) runs the command (30s timeout, dangerous/
|
||||
hardline-command guards, returns `{stdout, stderr, code}`).
|
||||
**Ink parity reference:** `ui-tui/src/app/useSubmission.ts:291` — `full.startsWith('!')` →
|
||||
`shellExec(full.slice(1).trim())` → appends a user line `!cmd` + a system line with output;
|
||||
the prompt glyph flips while the buffer starts with `!` (appLayout.tsx:178).
|
||||
|
||||
**Plan (ui-opentui):**
|
||||
- In the entry `submit` (main.tsx:517-520), add a branch BEFORE the slash check:
|
||||
`if (text.startsWith('!')) { runShell(text.slice(1).trim()); return }`.
|
||||
- `runShell(cmd)`: `store.pushUser('!' + cmd)` (echo the invocation in the transcript), then
|
||||
`gateway.request('shell.exec', { command: cmd })`; on resolve, `store.pushSystem` the
|
||||
combined `stdout`/`stderr` (or the error message / non-zero `code`); on reject,
|
||||
pushSystem the error. Detached `runFork` like `submitPrompt`. No session turn, no model call.
|
||||
- Empty `!` (just the bang) → no-op (or a hint), matching Ink.
|
||||
- **Optional polish (parity, not required):** flip the composer prompt glyph (or tint) while
|
||||
the buffer starts with `!`, like Ink's appLayout. Low-risk; do only if cheap.
|
||||
|
||||
**Tests:** entry-level/logic test that a `!`-prefixed submit routes to `shell.exec` (not
|
||||
`prompt.submit`), and the system line renders stdout. Mirror the slashMenu.test harness
|
||||
(fake gateway capturing the method).
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & fences (subagent-driven; disjoint files)
|
||||
|
||||
Parallel-safe groups (disjoint file fences):
|
||||
1. **slash trigger** — `logic/slash.ts` (+ `logic/skillMatch.ts` reuse) + `test/slash.test.ts`. (F1/F2/F7/F8/F8b)
|
||||
2. **clarify** — `view/prompts/clarifyPrompt.tsx` + a clarify test. (F5/F6)
|
||||
3. **shell-exec** — `entry/main.tsx` (edit DIRECTLY — load-bearing) + system render + test. (F9)
|
||||
4. **paste focus** — `view/composer.tsx` (edit directly; verify opentui paste API first). (F4)
|
||||
5. **cost** — `tui_gateway/server.py` + `agent/usage_pricing.py` + gateway test. (F3) — Python, isolated.
|
||||
|
||||
`entry/main.tsx` and `store.ts` are edited directly, never via subagent (handoff rule).
|
||||
Each renderable change: `skill_view(opentui, references/docs/...)` FIRST. Verify every
|
||||
subagent self-report (re-run `npm run check` exit code, read the diff).
|
||||
|
||||
## Open decisions (need glitch)
|
||||
- **D1 (F7/F8):** thread cursor offset into `onType` (correct) vs. last-line slice (cheap)?
|
||||
Recommend cursor offset.
|
||||
- **D2 (F3):** drop OpenRouter cost source entirely, or gate it to the Nous route? Recommend
|
||||
Nous-route gate via a status-bar-only helper, leaving `/usage` accounting intact.
|
||||
|
||||
## Invariants to preserve
|
||||
- Per-conversation prompt caching untouched (all client-render or post-hoc gateway usage).
|
||||
- No new `HERMES_*` env var (these are behaviour, not secrets).
|
||||
- Strict no change-detector tests — assert behaviour/invariants.
|
||||
- Don't regress the `/usage` accounting page when narrowing the chrome cost source.
|
||||
@@ -0,0 +1,217 @@
|
||||
# OpenTUI — usage/credits notice in the composer chrome
|
||||
|
||||
**Status:** spec (not started) · **Engine:** `ui-opentui/` · **Author:** glitch · 2026-06-14
|
||||
|
||||
## Goal
|
||||
|
||||
Render the gateway's **usage / credits notices** as a persistent, level-tinted
|
||||
**chrome banner pinned at the top of the input zone** (directly above the status
|
||||
bar), with the same lifecycle the Ink engine already has — sticky vs TTL,
|
||||
mid-turn hold + turn-end reveal, and "flash-and-yield" for the usage bands.
|
||||
|
||||
Today the OpenTUI engine **receives** these notices but mis-renders them as
|
||||
scrolling inline transcript cards with no lifecycle. This spec fixes that without
|
||||
touching the gateway or the agent (the data already flows correctly).
|
||||
|
||||
## What already exists (verified)
|
||||
|
||||
### The wire (source of truth — do NOT change)
|
||||
The gateway emits one event for every notice, snake_case payload:
|
||||
|
||||
```
|
||||
notification.show payload { text, level, kind, ttl_ms, key, id } # tui_gateway/server.py:2878
|
||||
notification.clear payload { key } # tui_gateway/server.py:2890
|
||||
```
|
||||
|
||||
These come from `AgentNotice` (`agent/credits_tracker.py:177`). The credits
|
||||
policy (`evaluate_credits_notices`, `agent/credits_tracker.py:245`) emits exactly
|
||||
four notices — the full catalog this feature renders:
|
||||
|
||||
| `key` | `text` (already glyphed by policy) | `level` | `kind` | `ttl_ms` | lifecycle |
|
||||
|-----------------------|-------------------------------------------------|-----------|----------|----------|----------------|
|
||||
| `credits.usage` | `⚠/• Credits N% used · $X cap` (bands 50/75/90) | info/warn | `sticky` | — | flash-and-yield |
|
||||
| `credits.grant_spent` | `• Grant spent · $X top-up left` | info | `sticky` | — | flash-and-yield |
|
||||
| `credits.depleted` | `✕ Credit access paused · run /usage for balance` | error | `sticky` | — | sticky |
|
||||
| `credits.restored` | `✓ Credit access restored` | success | `ttl` | `8000` | TTL self-expire |
|
||||
|
||||
**Load-bearing facts:**
|
||||
- `text` is **already glyphed** (⚠ • ✕ ✓) by the Python policy — the renderer
|
||||
**must not** prepend another glyph. It only tints by `level`.
|
||||
- `level` includes **`success`** (green) — a level the current OpenTUI parser
|
||||
silently drops to `info`.
|
||||
- `kind` is the **lifecycle marker** (`sticky` | `ttl`), NOT a display label.
|
||||
`id` == `key` (stable per kind, not unique per emission).
|
||||
- Notices are **reconciled**: the policy emits `to_clear` (a `notification.clear`)
|
||||
then `to_show`. A band change clears `credits.usage` then re-shows it.
|
||||
|
||||
### The Ink reference behavior (what we're matching)
|
||||
`ui-tui/src/app/turnController.ts` + `appChrome.tsx`:
|
||||
- `showNotice` (`:181`): if **busy**, hold in `pendingNotice` (latest-wins);
|
||||
if idle, apply now.
|
||||
- `applyNotice` (`:213`): set the visible notice; for `kind: 'ttl'` with
|
||||
`ttl_ms > 0`, arm a self-expiry timer (clearing any prior timer first).
|
||||
- `clearNotice(key)` (`:198`): drop the visible **and** pending notice only when
|
||||
the key matches (a stale clear must not wipe a newer notice).
|
||||
- `flushPendingNotice` (`:245`): at **turn end** (only the real end sites) apply
|
||||
the held notice — its TTL clock starts here, when it first becomes visible.
|
||||
- **Flash-and-yield** (`startMessage`, `:917`): at **turn start**, if the visible
|
||||
notice's key is `credits.usage` or `credits.grant_spent`, clear it — "show
|
||||
once, then get out of the way." `credits.depleted` and others stay sticky. The
|
||||
Python `active` latch keeps the key so it won't re-fire next turn.
|
||||
- Session reset clears all notice state so session A's notice can't bleed into B.
|
||||
- Color by level: `error→error`, `warn→warn`, `success→statusGood`,
|
||||
`info→accent` (`noticeColor`, `appChrome.tsx:192`).
|
||||
|
||||
### The OpenTUI side (what we change)
|
||||
- `notification.show` → `parseNotification` → `pushNotification` → **inline card**
|
||||
in the transcript (`store.ts:832`, `notificationCard.tsx`). All kinds, no
|
||||
lifecycle. The Option B process-completion card (`kind: 'process.complete'`)
|
||||
and `background.complete` (`kind: 'background task complete'`) also use this
|
||||
path — **they must keep working unchanged.**
|
||||
- `parseNotification` coerces `level` to `info|warn|error` only
|
||||
(`backgroundActivity.ts:48`) — drops `success`.
|
||||
- Store carries `lastNotification` (OSC seam), `bgTasks`; **no** `notice` slot.
|
||||
- Theme has `accent`, `warn`, `error`, `ok`/`statusGood`, `muted`
|
||||
(`logic/theme.ts`) — `success` maps to `statusGood`.
|
||||
- Input zone layout (`view/App.tsx:140-211`): a top-bordered column —
|
||||
`<StatusBar>` → composer `<Switch>` → `<AgentsTray>`. The new banner mounts at
|
||||
`App.tsx:144`, **directly above `<StatusBar>`** (the topmost line of the chrome).
|
||||
- Turn lifecycle hooks: `case 'message.start'` (`store.ts:779`, sets
|
||||
`info.running = true`) and `case 'message.complete'` (`store.ts:811`, sets
|
||||
`info.running = false`). `clearTranscript` (`store.ts:631`) is the reset site.
|
||||
- `Date.now()` is used freely in the store (`:877`) — `setTimeout` for TTL is fine.
|
||||
|
||||
## The one design decision: routing
|
||||
|
||||
`kind` is the discriminator. **`notification.show` with `kind === 'sticky'` or
|
||||
`kind === 'ttl'` → the new chrome-notice path; every other kind → the existing
|
||||
inline-card path, untouched.** This mirrors Ink's `Notice.kind: 'sticky' | 'ttl'`
|
||||
exactly, and the credits policy sets `kind` to one of those for all four notices,
|
||||
while the process/background cards use label-strings (`process.complete`,
|
||||
`background task complete`) that are neither — so they stay inline cards. No
|
||||
gateway change, no key-prefix sniffing.
|
||||
|
||||
**Divergence from Ink (intentional):** Ink hides the notice while busy because the
|
||||
FaceTicker shares its one status slot. OpenTUI's busy face (`StatusLine`) lives in
|
||||
the transcript area, so the banner has a **dedicated row** and stays visible
|
||||
through a turn (a depletion warning shouldn't vanish mid-turn). We still **hold
|
||||
new notices** that arrive mid-turn (`pendingNotice`) and reveal them at turn end —
|
||||
matching Ink's "don't pop a fresh banner mid-stream" intent.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1 — parser + type (`logic/backgroundActivity.ts`)
|
||||
1. Widen `ActivityNotification.level` to `'info' | 'warn' | 'error' | 'success'`.
|
||||
2. `coerceLevel`: also accept `'success'` (still fall back to `'info'`).
|
||||
3. Add `export function isChromeNotice(n: ActivityNotification): boolean` →
|
||||
`n.kind === 'sticky' || n.kind === 'ttl'`.
|
||||
4. `parseNotification` already maps `ttl_ms → ttlMs` and preserves `key`/`id` —
|
||||
no shape change beyond the widened level.
|
||||
|
||||
**Tests** (`backgroundActivity.test.ts` or `notificationCard.test.tsx`):
|
||||
`success` survives parse; `kind: 'ttl'` + `ttl_ms` → `ttlMs`; `isChromeNotice`
|
||||
true for sticky/ttl, false for `process.complete`/`''`.
|
||||
|
||||
### Phase 2 — store lifecycle (`logic/store.ts`)
|
||||
Add state + a private (non-reactive) timer handle in `createSessionStore`:
|
||||
- `notice: ActivityNotification | null` (visible chrome notice) — new state field,
|
||||
init `null`.
|
||||
- `pendingNotice: ActivityNotification | null` — held mid-turn, init `null`.
|
||||
- `let noticeTimer: ReturnType<typeof setTimeout> | undefined` (closure var).
|
||||
|
||||
Functions (port of `turnController`):
|
||||
- `showNotice(n)`: `state.info.running ? setState('pendingNotice', n) : applyNotice(n)`
|
||||
(latest-wins — assigning replaces any prior pending).
|
||||
- `applyNotice(n)`: clear `noticeTimer`; `setState('notice', n)`; if
|
||||
`n.kind === 'ttl' && n.ttlMs && n.ttlMs > 0`, arm `setTimeout(n.ttlMs)` that
|
||||
clears `notice` only if `state.notice?.id === n.id` (defensive guard).
|
||||
- `clearNotice(key)`: if `state.pendingNotice?.key === key` → null it; if
|
||||
`state.notice?.key === key` → clear timer + null `notice`.
|
||||
- `flushPendingNotice()`: if `state.pendingNotice` → `applyNotice` it, null pending.
|
||||
- `clearNoticeState()`: null `notice` + `pendingNotice`, clear timer.
|
||||
|
||||
Wire into the event reducer:
|
||||
- `notification.show` (`store.ts:832`): route —
|
||||
`const n = parseNotification(...); if (!n) break; if (isChromeNotice(n)) showNotice(n); else pushNotification(n)`.
|
||||
(Still record `lastNotification` for the OSC seam in **both** paths — extract
|
||||
the `setState('lastNotification', {...n})` so a chrome notice also pings a
|
||||
blurred terminal, matching the inline-card behavior.)
|
||||
- `notification.clear` (`store.ts:837`): call **both** `clearNotificationCards(key)`
|
||||
(cards) **and** `clearNotice(key)` (chrome) — a key only ever lives in one, so
|
||||
calling both is safe and avoids guessing.
|
||||
- `message.start` (`store.ts:779`): flash-and-yield — if
|
||||
`state.notice?.key === 'credits.usage' || === 'credits.grant_spent'` →
|
||||
`clearNotice(state.notice.key)`. (Do this **before** flipping `running` true so
|
||||
the read is clean.)
|
||||
- `message.complete` (`store.ts:811`): call `flushPendingNotice()` (after the
|
||||
`running = false` set, so a held notice reveals on the now-idle bar).
|
||||
- `clearTranscript` (`store.ts:631`) and any session-switch reset:
|
||||
`clearNoticeState()`.
|
||||
|
||||
Export `notice` via the store's state and `showNotice`/`clearNotice` if a test or
|
||||
future slash command needs them.
|
||||
|
||||
**Tests** (`statusNotice.test.ts`, new):
|
||||
- idle `showNotice` → `state.notice` set, no card pushed.
|
||||
- routing: `notification.show` `kind:'sticky'` → `notice` set, **no** transcript
|
||||
card; `kind:'process.complete'` → card pushed, `notice` still null.
|
||||
- mid-turn hold: `message.start` → `showNotice` → `notice` stays null,
|
||||
`pendingNotice` set → `message.complete` → `notice` revealed.
|
||||
- `clearNotice` by key drops visible + pending; non-matching key is a no-op.
|
||||
- TTL: `kind:'ttl', ttlMs:50` auto-clears (vitest fake timers).
|
||||
- flash-and-yield: visible `credits.usage` cleared on `message.start`;
|
||||
`credits.depleted` persists across a start/complete cycle.
|
||||
- `clearTranscript` resets `notice` + `pendingNotice`.
|
||||
- `success` notice keeps its level.
|
||||
|
||||
### Phase 3 — view (`view/noticeBanner.tsx` + `App.tsx`)
|
||||
New `NoticeBanner` (sibling style to `notificationCard.tsx`):
|
||||
- Props: `notice: ActivityNotification | null`, plus terminal width for truncation.
|
||||
- `<Show when={notice}>` — renders nothing when null.
|
||||
- One row, `flexShrink: 0`, `paddingLeft: 1`, `selectable={false}`.
|
||||
- Text rendered **verbatim** (glyph already present), tinted by level:
|
||||
`error→error`, `warn→warn`, `success→statusGood`, `info→accent`.
|
||||
- Truncate to width with `truncRight` (`logic/truncate.ts`) so a long notice can
|
||||
never push the composer or wrap.
|
||||
|
||||
Mount in `App.tsx:144`, the first child of the top-bordered input zone, directly
|
||||
above `<StatusBar store={...} />`:
|
||||
```tsx
|
||||
<box border={['top']} ...>
|
||||
<NoticeBanner notice={props.store.state.notice} /> {/* new */}
|
||||
<StatusBar store={props.store} />
|
||||
...
|
||||
```
|
||||
|
||||
**Tests** (`noticeBanner.test.tsx`, frame): renders the text without adding a
|
||||
glyph; warn→warn color, success→statusGood color; truncates at narrow width;
|
||||
renders an empty frame when `notice` is null.
|
||||
|
||||
### Phase 4 — parity verification + docs
|
||||
- `npm run check` green (prettier + eslint + vitest).
|
||||
- Headless frame dump: a `credits.usage` warn banner above the status bar; a
|
||||
`credits.depleted` error banner surviving a turn; a `credits.restored` success
|
||||
banner that disappears after its TTL.
|
||||
- tmux smoke per `docs/opentui-dev-handoff.md` (inject the three notices via the
|
||||
test harness / a scripted gateway event; screenshot the chrome).
|
||||
- Cross-check the four-notice catalog renders identically in tone to Ink's
|
||||
`appChromeStatusRule` (color-by-level, no double glyph, truncation).
|
||||
|
||||
## Non-goals
|
||||
- No gateway/agent changes — the wire and the policy are the source of truth.
|
||||
- No new notice kinds — render exactly the four the policy emits.
|
||||
- The inline-card path (process/background completions) is **unchanged**.
|
||||
- No status-bar segment changes — the banner is its own row above the bar.
|
||||
|
||||
## Risk / footguns
|
||||
- **Schema decode-at-boundary**: `notification.show` payload is a loose Record
|
||||
read by `parseNotification`, not strict-decoded — a wrong-typed field won't blank
|
||||
the bar (unlike `applyInfo`). Keep the loose reads.
|
||||
- **createStore reference-aliasing**: store `notice` and `pendingNotice` distinct
|
||||
objects; when applying pending, it's already its own object — don't alias it to
|
||||
`lastNotification`. (See `[[solid-createstore-reference-aliasing]]`.)
|
||||
- **Timer leak**: `clearNoticeState` must clear `noticeTimer`; ensure session
|
||||
reset and store dispose clear it so a TTL callback can't fire into a dead store.
|
||||
- **Routing regression**: assert in tests that `process.complete` /
|
||||
`background task complete` still produce **cards**, not banners — the whole
|
||||
feature hinges on the `kind` discriminator.
|
||||
+25
-24
@@ -77,6 +77,13 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
|
||||
return metadata
|
||||
|
||||
|
||||
def _mark_notify_metadata(metadata: dict | None) -> dict:
|
||||
"""Clone metadata and mark a user-visible reply as notify-worthy."""
|
||||
notify_metadata = dict(metadata) if metadata else {}
|
||||
notify_metadata["notify"] = True
|
||||
return notify_metadata
|
||||
|
||||
|
||||
def _reply_anchor_for_event(event) -> str | None:
|
||||
"""Return reply_to id for platforms that need reply semantics.
|
||||
|
||||
@@ -3889,7 +3896,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
content=_text,
|
||||
reply_to=_reply_anchor_for_event(event),
|
||||
metadata=thread_meta,
|
||||
metadata=_mark_notify_metadata(thread_meta),
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
@@ -3995,7 +4002,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
content=_text,
|
||||
reply_to=_reply_anchor_for_event(event),
|
||||
metadata=_thread_meta,
|
||||
metadata=_mark_notify_metadata(_thread_meta),
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
@@ -4045,7 +4052,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
content=_text,
|
||||
reply_to=_reply_anchor_for_event(event),
|
||||
metadata=_thread_meta,
|
||||
metadata=_mark_notify_metadata(_thread_meta),
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
@@ -4268,6 +4275,12 @@ class BasePlatformAdapter(ABC):
|
||||
)
|
||||
text_content = _recovered
|
||||
|
||||
# Final user-visible content (text, TTS, media, files) gets
|
||||
# the existing notify=True marker. Clone once so typing/status
|
||||
# metadata stays unmarked and progress bubbles remain
|
||||
# thread-strict.
|
||||
_final_thread_metadata = _mark_notify_metadata(_thread_metadata)
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
||||
@@ -4307,7 +4320,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
audio_path=_tts_path,
|
||||
caption=telegram_tts_caption,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
_tts_caption_delivered = bool(
|
||||
telegram_tts_caption and getattr(tts_result, "success", False)
|
||||
@@ -4322,23 +4335,11 @@ class BasePlatformAdapter(ABC):
|
||||
if text_content and not _tts_caption_delivered:
|
||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
||||
_reply_anchor = _reply_anchor_for_event(event)
|
||||
# Mark final response messages for notification delivery.
|
||||
# Platform adapters that support per-message notification
|
||||
# control (e.g. Telegram's disable_notification) use this
|
||||
# flag to override silent-mode and ensure the final
|
||||
# response triggers a push notification.
|
||||
# Clone to avoid mutating the metadata shared with the
|
||||
# typing-indicator task (which must remain unmarked).
|
||||
if _thread_metadata is not None:
|
||||
_thread_metadata = dict(_thread_metadata)
|
||||
_thread_metadata["notify"] = True
|
||||
else:
|
||||
_thread_metadata = {"notify": True}
|
||||
result = await self._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=text_content,
|
||||
reply_to=_reply_anchor,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
_record_delivery(result)
|
||||
|
||||
@@ -4367,7 +4368,7 @@ class BasePlatformAdapter(ABC):
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=images,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
except Exception as batch_err:
|
||||
@@ -4409,7 +4410,7 @@ class BasePlatformAdapter(ABC):
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=_batch,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
except Exception as batch_err:
|
||||
@@ -4424,19 +4425,19 @@ class BasePlatformAdapter(ABC):
|
||||
media_result = await self.send_voice(
|
||||
chat_id=event.source.chat_id,
|
||||
audio_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
media_result = await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
else:
|
||||
media_result = await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
file_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
|
||||
if not media_result.success:
|
||||
@@ -4454,13 +4455,13 @@ class BasePlatformAdapter(ABC):
|
||||
await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
else:
|
||||
await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
file_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
@@ -678,8 +678,13 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image URL as part of an email body."""
|
||||
"""Send an image URL as part of an email body.
|
||||
|
||||
``metadata`` is accepted to honor the base-class contract; the
|
||||
email body send doesn't use it.
|
||||
"""
|
||||
text = caption or ""
|
||||
text += f"\n\nImage: {image_url}"
|
||||
return await self.send(chat_id, text.strip(), reply_to)
|
||||
|
||||
@@ -846,13 +846,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Download image URL to cache, send natively via bridge."""
|
||||
"""Download image URL to cache, send natively via bridge.
|
||||
|
||||
``metadata`` is accepted to honor the base-class contract — the
|
||||
batch sender ``send_multiple_images`` passes it through to every
|
||||
send path. The bridge media call doesn't use it, matching the
|
||||
sibling overrides (send_video / send_voice / send_document).
|
||||
"""
|
||||
try:
|
||||
local_path = await cache_image_from_url(image_url)
|
||||
return await self._send_media_to_bridge(chat_id, local_path, "image", caption)
|
||||
except Exception:
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to, metadata)
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
@@ -1136,6 +1143,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
body = data.get("body", "")
|
||||
if data.get("isGroup"):
|
||||
body = self._clean_bot_mention_text(body, data)
|
||||
|
||||
# If this is a reply, include the quoted message text so the agent
|
||||
# knows exactly what the user is responding to (fixes "approve" context issue)
|
||||
quoted_text = str(data.get("quotedText") or "").strip()
|
||||
if quoted_text and data.get("hasQuotedMessage"):
|
||||
# Truncate long quoted text to keep prompts reasonable
|
||||
if len(quoted_text) > 300:
|
||||
quoted_text = quoted_text[:297] + "..."
|
||||
body = f"[Replying to: \"{quoted_text}\"]\n{body}"
|
||||
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
||||
if msg_type == MessageType.DOCUMENT and cached_urls:
|
||||
for doc_path in cached_urls:
|
||||
|
||||
+108
-14
@@ -413,6 +413,57 @@ def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_mess
|
||||
return None
|
||||
|
||||
|
||||
def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool:
|
||||
"""Return True when display.platforms.<platform> explicitly sets setting."""
|
||||
display = user_config.get("display") if isinstance(user_config, dict) else None
|
||||
if not isinstance(display, dict):
|
||||
return False
|
||||
platforms = display.get("platforms")
|
||||
if not isinstance(platforms, dict):
|
||||
return False
|
||||
platform_cfg = platforms.get(platform_key)
|
||||
return isinstance(platform_cfg, dict) and setting in platform_cfg
|
||||
|
||||
|
||||
def _resolve_gateway_display_bool(
|
||||
user_config: dict,
|
||||
platform_key: str,
|
||||
setting: str,
|
||||
*,
|
||||
default: bool = False,
|
||||
platform: Any = None,
|
||||
require_platform_override_for: set[Any] | None = None,
|
||||
) -> bool:
|
||||
"""Resolve a boolean display setting with optional platform-only opt-in.
|
||||
|
||||
Some display features expose assistant scratch text rather than deliberate
|
||||
user-facing output. For high-noise threaded chat surfaces such as
|
||||
Mattermost, a global opt-in is too broad: they must be enabled with an
|
||||
explicit display.platforms.<platform>.<setting> override.
|
||||
"""
|
||||
current_platform = _gateway_platform_value(platform or platform_key)
|
||||
platform_only = {
|
||||
_gateway_platform_value(candidate)
|
||||
for candidate in (require_platform_override_for or set())
|
||||
}
|
||||
if (
|
||||
current_platform in platform_only
|
||||
and not _has_platform_display_override(user_config, platform_key, setting)
|
||||
):
|
||||
return False
|
||||
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
value = resolve_display_setting(user_config, platform_key, setting, default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"true", "yes", "1", "on"}
|
||||
if value is None:
|
||||
return bool(default)
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _telegramize_command_mentions(text: str, platform: Any) -> str:
|
||||
"""Rewrite slash-command mentions to Telegram-valid command names.
|
||||
|
||||
@@ -8989,17 +9040,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
source, session_entry, reason="agent-result-compression",
|
||||
)
|
||||
|
||||
# Prepend reasoning/thinking if display is enabled (per-platform)
|
||||
# Prepend reasoning/thinking if display is enabled (per-platform).
|
||||
# Mattermost requires explicit per-platform opt-in because this is
|
||||
# scratch text, not ordinary final-answer content.
|
||||
try:
|
||||
from gateway.display_config import resolve_display_setting as _rds
|
||||
_show_reasoning_effective = _rds(
|
||||
_show_reasoning_effective = _resolve_gateway_display_bool(
|
||||
_load_gateway_config(),
|
||||
_platform_config_key(source.platform),
|
||||
"show_reasoning",
|
||||
getattr(self, "_show_reasoning", False),
|
||||
default=bool(getattr(self, "_show_reasoning", False)),
|
||||
platform=source.platform,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
)
|
||||
except Exception:
|
||||
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
|
||||
_show_reasoning_effective = (
|
||||
False
|
||||
if source.platform == Platform.MATTERMOST
|
||||
else getattr(self, "_show_reasoning", False)
|
||||
)
|
||||
if _show_reasoning_effective and response and not _intentional_silence:
|
||||
last_reasoning = agent_result.get("last_reasoning")
|
||||
if last_reasoning:
|
||||
@@ -13635,18 +13693,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# in chat platforms while opting into concise mid-turn updates.
|
||||
interim_assistant_messages_enabled = (
|
||||
source.platform != Platform.WEBHOOK
|
||||
and bool(
|
||||
resolve_display_setting(
|
||||
user_config,
|
||||
platform_key,
|
||||
"interim_assistant_messages",
|
||||
True,
|
||||
)
|
||||
and _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
platform_key,
|
||||
"interim_assistant_messages",
|
||||
default=True,
|
||||
platform=source.platform,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
)
|
||||
)
|
||||
|
||||
# thinking_progress is independent — if enabled, we need the progress
|
||||
# queue even when tool_progress is off (thinking relay uses same infra).
|
||||
# Mattermost requires a per-platform opt-in: global scratch-text display
|
||||
# is too easy to leak into busy public threads.
|
||||
_thinking_enabled = _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
platform_key,
|
||||
"thinking_progress",
|
||||
default=False,
|
||||
platform=source.platform,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
)
|
||||
needs_progress_queue = tool_progress_enabled or _thinking_enabled
|
||||
|
||||
|
||||
# Queue for progress messages (thread-safe)
|
||||
progress_queue = queue.Queue() if tool_progress_enabled else None
|
||||
progress_queue = queue.Queue() if needs_progress_queue else None
|
||||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
@@ -13752,6 +13824,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
logger.debug("tool-progress onboarding hint failed: %s", _hint_err)
|
||||
return
|
||||
|
||||
# "_thinking" is assistant scratch text between tool calls. It
|
||||
# is never ordinary tool progress: only relay it when the platform
|
||||
# explicitly opted into thinking_progress. Handle both legacy
|
||||
# callback shapes: ("_thinking", text) and
|
||||
# ("reasoning.available", "_thinking", text, ...).
|
||||
if event_type == "_thinking" or tool_name == "_thinking":
|
||||
if not _thinking_enabled:
|
||||
return
|
||||
thinking_text = preview if tool_name == "_thinking" else tool_name
|
||||
msg = f"💬 {thinking_text}" if thinking_text else None
|
||||
if msg:
|
||||
progress_queue.put(msg)
|
||||
return
|
||||
|
||||
# If tool_progress is off, only _thinking passes through (above).
|
||||
# Regular tool calls are suppressed.
|
||||
if not tool_progress_enabled:
|
||||
return
|
||||
|
||||
# Only act on tool.started events (ignore tool.completed, reasoning.available, etc.)
|
||||
if event_type not in {"tool.started",}:
|
||||
@@ -14783,6 +14873,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
agent.clarify_callback = _clarify_callback_sync
|
||||
|
||||
# Show assistant thinking between tool calls — independent of
|
||||
# tool_progress mode. Mattermost needs an explicit per-platform
|
||||
# opt-in so global scratch-text display does not leak into threads.
|
||||
agent.thinking_progress = _thinking_enabled
|
||||
# Store agent reference for interrupt support
|
||||
agent_holder[0] = agent
|
||||
# Capture the full tool definitions for transcript logging
|
||||
|
||||
+43
-10
@@ -197,6 +197,30 @@ class GatewayStreamConsumer:
|
||||
# this response and route through edit-based for graceful degradation.
|
||||
self._draft_failures = 0
|
||||
|
||||
def _metadata_for_send(
|
||||
self,
|
||||
*,
|
||||
final: bool = False,
|
||||
expect_edits: bool = False,
|
||||
) -> dict | None:
|
||||
"""Return per-send metadata for stream-created messages.
|
||||
|
||||
Mattermost treats notify-worthy sends as user-visible final content
|
||||
when deciding whether a broken thread root may fall back flat. Preview
|
||||
and progress sends keep their original metadata and remain thread-strict.
|
||||
|
||||
``expect_edits`` preserves the upstream Telegram streaming contract:
|
||||
preview messages that may be edited later must stay on the editable
|
||||
legacy send path, while fresh/fallback final sends can still use richer
|
||||
final-message delivery.
|
||||
"""
|
||||
meta = dict(self.metadata) if self.metadata else {}
|
||||
if expect_edits:
|
||||
meta["expect_edits"] = True
|
||||
if final:
|
||||
meta["notify"] = True
|
||||
return meta or None
|
||||
|
||||
@property
|
||||
def already_sent(self) -> bool:
|
||||
"""True if at least one message was sent or edited during the run."""
|
||||
@@ -513,7 +537,11 @@ class GatewayStreamConsumer:
|
||||
chunks_delivered = False
|
||||
reply_to = self._message_id or self._initial_reply_to_id
|
||||
for chunk in chunks:
|
||||
new_id = await self._send_new_chunk(chunk, reply_to)
|
||||
new_id = await self._send_new_chunk(
|
||||
chunk,
|
||||
reply_to,
|
||||
final=got_done,
|
||||
)
|
||||
if new_id is not None and new_id != reply_to:
|
||||
chunks_delivered = True
|
||||
self._accumulated = ""
|
||||
@@ -749,7 +777,13 @@ class GatewayStreamConsumer:
|
||||
# Strip trailing whitespace/newlines but preserve leading content
|
||||
return cleaned.rstrip()
|
||||
|
||||
async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]:
|
||||
async def _send_new_chunk(
|
||||
self,
|
||||
text: str,
|
||||
reply_to_id: Optional[str],
|
||||
*,
|
||||
final: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Send a new message chunk, optionally threaded to a previous message.
|
||||
|
||||
Returns the message_id so callers can thread subsequent chunks.
|
||||
@@ -758,15 +792,11 @@ class GatewayStreamConsumer:
|
||||
if not text.strip():
|
||||
return reply_to_id
|
||||
try:
|
||||
meta = dict(self.metadata) if self.metadata else {}
|
||||
# This chunk becomes the next edit target — adapters that support
|
||||
# rich final sends (Telegram) must keep it on the editable path.
|
||||
meta["expect_edits"] = True
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=reply_to_id,
|
||||
metadata=meta,
|
||||
metadata=self._metadata_for_send(final=final, expect_edits=True),
|
||||
)
|
||||
if result.success and result.message_id:
|
||||
self._message_id = str(result.message_id)
|
||||
@@ -885,7 +915,7 @@ class GatewayStreamConsumer:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=chunk,
|
||||
metadata=self.metadata,
|
||||
metadata=self._metadata_for_send(final=True),
|
||||
)
|
||||
if result.success:
|
||||
break
|
||||
@@ -1242,7 +1272,7 @@ class GatewayStreamConsumer:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
metadata=self.metadata,
|
||||
metadata=self._metadata_for_send(final=True),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
|
||||
@@ -1532,7 +1562,10 @@ class GatewayStreamConsumer:
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=self._initial_reply_to_id,
|
||||
metadata={**(self.metadata or {}), "expect_edits": True},
|
||||
metadata=self._metadata_for_send(
|
||||
final=finalize,
|
||||
expect_edits=True,
|
||||
),
|
||||
)
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
|
||||
+16
-2
@@ -145,8 +145,16 @@ def build_top_level_parser():
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
# nargs="?" + const=True: bare `--resume` parses to the sentinel True,
|
||||
# which `hermes --tui` turns into the session picker
|
||||
# (HERMES_TUI_RESUME=picker). `--resume <id|title>` is unchanged.
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
help=(
|
||||
"Resume a previous session by ID or title. With --tui, bare "
|
||||
"--resume (no argument) opens the session picker."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
@@ -301,8 +309,14 @@ def build_top_level_parser():
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
# Same bare-flag picker sentinel as the top-level --resume.
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
help=(
|
||||
"Resume a previous session by ID (shown on exit). With --tui, "
|
||||
"bare --resume opens the session picker."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
|
||||
+394
-5
@@ -1640,8 +1640,286 @@ def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
|
||||
return bundled if bundled.is_file() else None
|
||||
|
||||
|
||||
def _config_tui_engine_early() -> str | None:
|
||||
"""Read ``display.tui_engine`` from config via a minimal YAML read.
|
||||
|
||||
Returns the configured engine string, or ``None`` when unset/unreadable so the
|
||||
caller can apply the availability-gated default. Mirrors
|
||||
:func:`_config_default_interface_early`.
|
||||
"""
|
||||
try:
|
||||
home = os.environ.get("HERMES_HOME")
|
||||
cfg_path = (
|
||||
os.path.join(home, "config.yaml")
|
||||
if home
|
||||
else os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
||||
)
|
||||
if os.path.exists(cfg_path):
|
||||
import yaml as _yaml_eng
|
||||
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
raw = _yaml_eng.safe_load(_f) or {}
|
||||
disp = raw.get("display", {})
|
||||
if isinstance(disp, dict):
|
||||
eng = disp.get("tui_engine")
|
||||
if isinstance(eng, str) and eng.strip():
|
||||
return eng.strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_tui_engine() -> str:
|
||||
"""Which TUI engine to launch: "ink" (default) or "opentui".
|
||||
|
||||
Precedence: ``HERMES_TUI_ENGINE`` env > ``display.tui_engine`` config >
|
||||
(OpenTUI when this host can run it — Node >= 26.3 + the built package — else Ink).
|
||||
The OpenTUI engine runs on Node 26.3+ via the experimental ``node:ffi`` renderer,
|
||||
which is not validated on Windows or Termux — a request for "opentui" there falls
|
||||
back to "ink" with a notice so a stale flag never strands the user on an engine
|
||||
that can't start.
|
||||
"""
|
||||
env = (os.environ.get("HERMES_TUI_ENGINE") or "").strip().lower()
|
||||
# Explicit choice (env > config) wins; otherwise default to OpenTUI when this
|
||||
# host is genuinely set up for it (Node >= 26.3 + the built bundle), else Ink.
|
||||
engine = env or _config_tui_engine_early() or ("opentui" if _opentui_available() else "ink")
|
||||
if engine != "opentui":
|
||||
return "ink"
|
||||
|
||||
# opentui requested — gate on platform support.
|
||||
unsupported = sys.platform.startswith("win") or _is_termux_startup_environment()
|
||||
if unsupported:
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
where = "Windows" if sys.platform.startswith("win") else "Termux"
|
||||
print(
|
||||
f"HERMES_TUI_ENGINE=opentui is not supported on {where} "
|
||||
f"(needs Node 26.3+ with experimental FFI) — falling back to the Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return "ink"
|
||||
return "opentui"
|
||||
|
||||
|
||||
NODE26_MIN_VERSION = (26, 3, 0)
|
||||
|
||||
|
||||
def _node_version_tuple(node_bin: str) -> tuple[int, int, int] | None:
|
||||
"""Return (major, minor, patch) for a node binary, or ``None`` if unreadable."""
|
||||
try:
|
||||
out = subprocess.run([node_bin, "--version"], capture_output=True, text=True, timeout=5)
|
||||
except Exception:
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
raw = (out.stdout or "").strip().lstrip("v").split("-", 1)[0]
|
||||
parts = raw.split(".")
|
||||
try:
|
||||
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _fnm_node26_candidates() -> list[str]:
|
||||
"""Node binaries from fnm's installed versions, newest first.
|
||||
|
||||
fnm keeps each version at ``<FNM_DIR>/node-versions/v<X.Y.Z>/installation/
|
||||
bin/node`` (default ``FNM_DIR``: ``$XDG_DATA_HOME/fnm`` or ``~/.local/share/
|
||||
fnm``; macOS Homebrew also uses ``~/Library/Application Support/fnm``). When
|
||||
the *active* node is older than 26.3 — e.g. the user's fnm default is on
|
||||
v25 — the right 26.x is still installed and usable; surface it so OpenTUI
|
||||
works without the user re-aliasing their global default. Version-sorted so
|
||||
the newest qualifying node wins.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
fnm_dir = os.environ.get("FNM_DIR")
|
||||
if fnm_dir:
|
||||
roots.append(Path(fnm_dir))
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg:
|
||||
roots.append(Path(xdg) / "fnm")
|
||||
roots.append(Path.home() / ".local" / "share" / "fnm")
|
||||
roots.append(Path.home() / "Library" / "Application Support" / "fnm")
|
||||
|
||||
seen: set[Path] = set()
|
||||
found: list[tuple[tuple[int, int, int], str]] = []
|
||||
for root in roots:
|
||||
versions_dir = root / "node-versions"
|
||||
if versions_dir in seen or not versions_dir.is_dir():
|
||||
continue
|
||||
seen.add(versions_dir)
|
||||
try:
|
||||
entries = list(versions_dir.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
node_bin = entry / "installation" / "bin" / "node"
|
||||
if not (node_bin.is_file() and os.access(node_bin, os.X_OK)):
|
||||
continue
|
||||
# Trust the directory name for sorting; the real probe happens in
|
||||
# the caller (a renamed/symlinked dir still gets version-checked).
|
||||
name = entry.name.lstrip("v").split("-", 1)[0]
|
||||
parts = name.split(".")
|
||||
try:
|
||||
ver = (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except (IndexError, ValueError):
|
||||
ver = (0, 0, 0)
|
||||
found.append((ver, str(node_bin)))
|
||||
found.sort(key=lambda pair: pair[0], reverse=True)
|
||||
return [path for _, path in found]
|
||||
|
||||
|
||||
def _node26_bin_or_none() -> str | None:
|
||||
"""Resolve a Node >= 26.3.0 binary (no exit — a probe), or ``None``.
|
||||
|
||||
Order: ``HERMES_NODE`` override > ``node`` on PATH > newest fnm-installed
|
||||
version. Each is gated on the real ``--version`` being >= 26.3.0. OpenTUI's
|
||||
native renderer loads via the experimental ``node:ffi`` API that only exists
|
||||
on Node 26.3+, so an older Node is treated as "not available" — but an
|
||||
installed-yet-inactive 26.x (common when fnm's default is on an older line)
|
||||
is discovered and used so the engine still launches.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
env_node = os.environ.get("HERMES_NODE")
|
||||
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
|
||||
candidates.append(env_node)
|
||||
path = shutil.which("node")
|
||||
if path:
|
||||
candidates.append(path)
|
||||
candidates.extend(_fnm_node26_candidates())
|
||||
for cand in candidates:
|
||||
ver = _node_version_tuple(cand)
|
||||
if ver is not None and ver >= NODE26_MIN_VERSION:
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def _node26_bin() -> str:
|
||||
"""Resolve Node >= 26.3.0 for the OpenTUI engine, or exit with a clear message.
|
||||
|
||||
Use :func:`_node26_bin_or_none` for a non-fatal availability probe.
|
||||
"""
|
||||
node = _node26_bin_or_none()
|
||||
if node is not None:
|
||||
return node
|
||||
print(
|
||||
"Node.js >= 26.3.0 not found — the OpenTUI TUI engine needs it for the "
|
||||
"experimental node:ffi renderer.\n"
|
||||
"Install Node 26.3+ (e.g. via fnm/nvm) or set HERMES_NODE=/path/to/node, "
|
||||
"or unset HERMES_TUI_ENGINE to use the default Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _opentui_npm() -> str:
|
||||
"""Resolve npm (ships with Node) to build the OpenTUI bundle, or exit."""
|
||||
npm = shutil.which("npm")
|
||||
if npm:
|
||||
return npm
|
||||
print(
|
||||
"npm not found — needed to build the OpenTUI engine bundle.\n"
|
||||
"Install Node 26.3+ (it ships npm), or unset HERMES_TUI_ENGINE for Ink.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _opentui_available() -> bool:
|
||||
"""Whether the OpenTUI engine can actually launch on this host.
|
||||
|
||||
True only when the platform is supported (not Windows/Termux), a Node >= 26.3
|
||||
binary resolves (the node:ffi floor), AND the v2 package is BUILT
|
||||
(``dist/main.js``) with its ``node_modules`` installed. This gates the DEFAULT
|
||||
engine: a host genuinely set up for OpenTUI defaults to it; everyone else stays
|
||||
on Ink. An explicit ``HERMES_TUI_ENGINE`` env or ``display.tui_engine`` config
|
||||
choice bypasses this probe (and triggers an on-demand build).
|
||||
"""
|
||||
if sys.platform.startswith("win") or _is_termux_startup_environment():
|
||||
return False
|
||||
if _node26_bin_or_none() is None:
|
||||
return False
|
||||
pkg = PROJECT_ROOT / "ui-opentui"
|
||||
built = pkg / "dist" / "main.js"
|
||||
return built.is_file() and (pkg / "node_modules" / "@opentui").is_dir()
|
||||
|
||||
|
||||
def _make_opentui_argv(tui_dev: bool) -> tuple[list[str], Path]:
|
||||
"""Argv for the native OpenTUI engine under Node 26 (no Bun).
|
||||
|
||||
Builds the Solid + Effect-at-boundary engine (``ui-opentui``) with esbuild
|
||||
(``npm run build`` → ``dist/main.js``) when the bundle is missing (or always, in
|
||||
``--dev``), then launches it on Node with the experimental FFI flag:
|
||||
|
||||
node --experimental-ffi --no-warnings dist/main.js
|
||||
|
||||
``--no-warnings`` keeps the ExperimentalWarning off the TUI's stderr. Returns the
|
||||
argv and the package cwd.
|
||||
|
||||
The spawned ``tui_gateway`` resolves its Python from ``HERMES_PYTHON_SRC_ROOT``
|
||||
(the caller sets it to ``PROJECT_ROOT``); the built bundle's own fallback also
|
||||
walks up to the checkout root, so the gateway resolves correctly either way.
|
||||
"""
|
||||
app_dir = PROJECT_ROOT / "ui-opentui"
|
||||
entry_src = app_dir / "src" / "entry" / "main.tsx"
|
||||
if not entry_src.is_file():
|
||||
print(
|
||||
f"OpenTUI v2 engine entry not found at {entry_src}.\n"
|
||||
f"Unset HERMES_TUI_ENGINE to use the default Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
node = _node26_bin()
|
||||
|
||||
# The esbuild build needs the package's node_modules (esbuild + the @opentui
|
||||
# packages + the native blob). Without them the build/launch dies cryptically.
|
||||
if not (app_dir / "node_modules" / "@opentui").is_dir():
|
||||
print(
|
||||
f"OpenTUI engine dependencies are not installed in {app_dir}.\n"
|
||||
f"Run: (cd {app_dir} && npm install)\n"
|
||||
f"Or unset HERMES_TUI_ENGINE to use the default Ink engine.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
built = app_dir / "dist" / "main.js"
|
||||
if tui_dev or not built.is_file():
|
||||
npm = _opentui_npm()
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
print("Building the OpenTUI engine…", file=sys.stderr)
|
||||
result = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=str(app_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
preview = "\n".join(combined.splitlines()[-30:])
|
||||
print("OpenTUI engine build failed.", file=sys.stderr)
|
||||
if preview:
|
||||
print(preview, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --expose-gc (parity with Ink, main.py ~1909): makes `global.gc()` a real
|
||||
# callable so the OpenTUI engine's GC hooks (W2 proactive idle GC; /heapdump)
|
||||
# work instead of being silent no-ops. MUST be an argv flag — Node rejects
|
||||
# --expose-gc in NODE_OPTIONS (see the heap-cap injection below).
|
||||
return [node, "--experimental-ffi", "--no-warnings", "--expose-gc", str(built)], app_dir
|
||||
|
||||
|
||||
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
|
||||
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).
|
||||
|
||||
Dual-engine: when ``HERMES_TUI_ENGINE``/``display.tui_engine`` selects the
|
||||
native OpenTUI engine, dispatch to ``_make_opentui_argv`` (Node 26 + its own
|
||||
esbuild build) BEFORE the Ink Node bootstrap — the OpenTUI engine resolves its
|
||||
own Node >= 26.3 and builds its own bundle, so it must not be routed through
|
||||
``_ensure_tui_node`` / the Ink prebuilt-dir logic.
|
||||
"""
|
||||
if _resolve_tui_engine() == "opentui":
|
||||
return _make_opentui_argv(tui_dev)
|
||||
|
||||
_ensure_tui_node()
|
||||
|
||||
def _node_bin(bin: str) -> str:
|
||||
@@ -1877,6 +2155,57 @@ def _read_cgroup_memory_limit() -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _config_tui_heap_mb_early() -> int | None:
|
||||
"""Read ``display.tui_heap_mb`` from config via a minimal YAML read.
|
||||
|
||||
Returns the configured V8 heap cap in MB, or ``None`` when unset/unreadable.
|
||||
Mirrors :func:`_config_tui_engine_early`. A non-secret behavioral setting, so
|
||||
it lives in ``config.yaml`` (NOT a ``HERMES_*`` env / the NODE_OPTIONS bridge,
|
||||
which is denylisted) — the ``HERMES_TUI_HEAP_MB`` env is only the per-launch
|
||||
override on top of this.
|
||||
"""
|
||||
try:
|
||||
home = os.environ.get("HERMES_HOME")
|
||||
cfg_path = (
|
||||
os.path.join(home, "config.yaml")
|
||||
if home
|
||||
else os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
||||
)
|
||||
if os.path.exists(cfg_path):
|
||||
import yaml as _yaml_heap
|
||||
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
raw = _yaml_heap.safe_load(_f) or {}
|
||||
disp = raw.get("display", {})
|
||||
if isinstance(disp, dict):
|
||||
val = disp.get("tui_heap_mb")
|
||||
if isinstance(val, bool): # guard: YAML true/false is an int subclass
|
||||
return None
|
||||
if isinstance(val, int) and val > 0:
|
||||
return val
|
||||
if isinstance(val, str) and val.strip().isdigit():
|
||||
n = int(val.strip())
|
||||
if n > 0:
|
||||
return n
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_tui_heap_override() -> int | None:
|
||||
"""The user's explicit V8 heap cap (MB), or ``None`` for the default path.
|
||||
|
||||
Precedence: ``HERMES_TUI_HEAP_MB`` env > ``display.tui_heap_mb`` config
|
||||
(matches the ``HERMES_TUI_ENGINE`` env-first pattern). Honored by BOTH engines
|
||||
via the shared ``NODE_OPTIONS`` injection. A positive integer wins; anything
|
||||
else (unset/garbage/non-positive) falls through to the cgroup-aware default.
|
||||
"""
|
||||
env_val = os.environ.get("HERMES_TUI_HEAP_MB", "").strip()
|
||||
if env_val.isdigit() and int(env_val) > 0:
|
||||
return int(env_val)
|
||||
return _config_tui_heap_mb_early()
|
||||
|
||||
|
||||
def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
||||
"""Pick a V8 ``--max-old-space-size`` (MB) that fits the container.
|
||||
|
||||
@@ -1885,7 +2214,16 @@ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
||||
cgroup limit so the heap + non-heap RSS stays under the cgroup ceiling,
|
||||
clamped to a sane floor (1536MB — below this V8 GC-thrashes and the TUI
|
||||
is barely usable). Never exceeds ``default_mb``.
|
||||
|
||||
An explicit ``HERMES_TUI_HEAP_MB`` env / ``display.tui_heap_mb`` config
|
||||
override REPLACES the 8192 default (D3): setting it low is the low-mem opt-in,
|
||||
setting it high raises the ceiling. The cgroup-fit clamp still applies on top
|
||||
so an override never exceeds what the container can hold — a low override is
|
||||
honored as-is, a too-high one is still trimmed to ~75% of the cgroup limit.
|
||||
"""
|
||||
override = _resolve_tui_heap_override()
|
||||
if override is not None:
|
||||
default_mb = override
|
||||
limit = _read_cgroup_memory_limit()
|
||||
if not limit:
|
||||
return default_mb
|
||||
@@ -1902,7 +2240,8 @@ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
||||
|
||||
|
||||
def _launch_tui(
|
||||
resume_session_id: Optional[str] = None,
|
||||
# str session id, the bare-`--resume` picker sentinel True, or None.
|
||||
resume_session_id: "Optional[str | bool]" = None,
|
||||
tui_dev: bool = False,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
@@ -1921,6 +2260,14 @@ def _launch_tui(
|
||||
"""Replace current process with the TUI."""
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
|
||||
# Bare `--resume` arrives as the argparse sentinel True: open the TUI
|
||||
# resume picker instead of resuming a specific session id. Normalize it
|
||||
# here so everything downstream (exit summary, env forwarding) keeps
|
||||
# seeing either a real session id string or None.
|
||||
resume_picker = resume_session_id is True
|
||||
if resume_picker:
|
||||
resume_session_id = None
|
||||
|
||||
import tempfile
|
||||
|
||||
env = os.environ.copy()
|
||||
@@ -1934,11 +2281,31 @@ def _launch_tui(
|
||||
)
|
||||
os.close(active_session_fd)
|
||||
env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file
|
||||
# Tree-sitter grammar cache for the OpenTUI engine: grammars are fetched
|
||||
# from GitHub on first use and cached here (profile-aware). Unset → OpenTUI
|
||||
# falls back to its XDG default ($XDG_DATA_HOME/opentui). See
|
||||
# ui-opentui/src/boundary/parsers.ts.
|
||||
try:
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
env["HERMES_TUI_PARSER_CACHE"] = str(
|
||||
get_hermes_home() / "cache" / "opentui-parsers"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve OpenTUI parser cache dir", exc_info=True)
|
||||
env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get(
|
||||
"HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT)
|
||||
)
|
||||
env.setdefault("HERMES_PYTHON", sys.executable)
|
||||
env.setdefault("HERMES_CWD", os.getcwd())
|
||||
# The TUI subprocess is launched with cwd=<engine package dir> (so its
|
||||
# build/resolution works), which means the gateway it spawns would otherwise
|
||||
# auto-detect THAT dir as the workspace (chrome bar showed "ui-opentui" no
|
||||
# matter where you ran hermes). TERMINAL_CWD is the gateway's canonical
|
||||
# launch-dir channel (_completion_cwd) — set it to the real cwd here so the
|
||||
# session, chrome bar, and terminal tool all anchor to where you actually
|
||||
# are. Worktree mode overrides it to the worktree path below.
|
||||
env.setdefault("TERMINAL_CWD", os.getcwd())
|
||||
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
|
||||
|
||||
wt_info = None
|
||||
@@ -2015,6 +2382,11 @@ def _launch_tui(
|
||||
# --expose-gc is *not* added here: Node rejects it in NODE_OPTIONS
|
||||
# ("--expose-gc is not allowed in NODE_OPTIONS") and refuses to start.
|
||||
# It is passed as a direct argv flag in _make_tui_argv() instead.
|
||||
#
|
||||
# Both TUI engines run on Node/V8 now — Ink, and the native OpenTUI engine
|
||||
# (Node 26 + node:ffi). So --max-old-space-size (a V8/Node flag) applies to
|
||||
# both. (Pre-Node-26 the OpenTUI engine ran on Bun/JavaScriptCore, which has
|
||||
# no such flag; that gate is gone now that the engine is Node.)
|
||||
_tokens = env.get("NODE_OPTIONS", "").split()
|
||||
if not any(t.startswith("--max-old-space-size=") for t in _tokens):
|
||||
_tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}")
|
||||
@@ -2027,7 +2399,11 @@ def _launch_tui(
|
||||
# resolved for this invocation; direct `node ui-tui/dist/entry.js` users can
|
||||
# still set HERMES_TUI_RESUME themselves.
|
||||
env.pop("HERMES_TUI_RESUME", None)
|
||||
if resume_session_id:
|
||||
if resume_picker:
|
||||
# Bare --resume: tell the TUI to open the resume picker before any
|
||||
# session.create (create is lazy, so nothing is wasted).
|
||||
env["HERMES_TUI_RESUME"] = "picker"
|
||||
elif resume_session_id:
|
||||
env["HERMES_TUI_RESUME"] = resume_session_id
|
||||
|
||||
argv, cwd = _make_tui_argv(tui_dir, tui_dev)
|
||||
@@ -2136,6 +2512,18 @@ def cmd_chat(args):
|
||||
"""Run interactive chat CLI."""
|
||||
use_tui = _resolve_use_tui(args)
|
||||
|
||||
# Bare `--resume` (argparse sentinel True) opens the TUI resume picker —
|
||||
# `_launch_tui` translates it to HERMES_TUI_RESUME=picker. The classic
|
||||
# REPL has no picker overlay, so point at the equivalents instead of
|
||||
# silently resuming something the user didn't choose.
|
||||
if getattr(args, "resume", None) is True and not use_tui:
|
||||
print("Bare --resume opens the session picker, which requires the TUI.")
|
||||
print(
|
||||
"Use 'hermes --tui --resume', 'hermes --resume <id|title>', "
|
||||
"'hermes -c', or 'hermes sessions browse'."
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Resolve --continue into --resume with the latest session or by name
|
||||
continue_val = getattr(args, "continue_last", None)
|
||||
if continue_val and not getattr(args, "resume", None):
|
||||
@@ -2161,9 +2549,10 @@ def cmd_chat(args):
|
||||
print(f"No previous {kind} session found to continue.")
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve --resume by title if it's not a direct session ID
|
||||
# Resolve --resume by title if it's not a direct session ID. The bare
|
||||
# picker sentinel (True) is not a name — leave it for _launch_tui.
|
||||
resume_val = getattr(args, "resume", None)
|
||||
if resume_val:
|
||||
if resume_val and resume_val is not True:
|
||||
resolved = _resolve_session_by_name_or_id(resume_val)
|
||||
if resolved:
|
||||
args.resume = resolved
|
||||
|
||||
+78
-1
@@ -268,7 +268,7 @@ emit_manifest() {
|
||||
if [ "$INCLUDE_DESKTOP" = true ]; then
|
||||
desktop_stage='{"name":"desktop","title":"Build desktop app","category":"runtime","needs_user_input":false},'
|
||||
fi
|
||||
printf '%s' '{"protocol_version":1,"stages":[{"name":"prerequisites","title":"System prerequisites","category":"runtime","needs_user_input":false},{"name":"repository","title":"Download Hermes Agent","category":"runtime","needs_user_input":false},{"name":"venv","title":"Create Python virtual environment","category":"runtime","needs_user_input":false},{"name":"python-deps","title":"Install Python dependencies","category":"runtime","needs_user_input":false},{"name":"node-deps","title":"Install browser-tool dependencies","category":"runtime","needs_user_input":false},{"name":"path","title":"Install hermes command","category":"runtime","needs_user_input":false},{"name":"config","title":"Prepare config and skills","category":"configuration","needs_user_input":false},{"name":"setup","title":"Configure API keys and settings","category":"configuration","needs_user_input":true},{"name":"gateway","title":"Configure gateway service","category":"configuration","needs_user_input":true},'"$desktop_stage"'{"name":"complete","title":"Finish install","category":"runtime","needs_user_input":false}]}'
|
||||
printf '%s' '{"protocol_version":1,"stages":[{"name":"prerequisites","title":"System prerequisites","category":"runtime","needs_user_input":false},{"name":"repository","title":"Download Hermes Agent","category":"runtime","needs_user_input":false},{"name":"venv","title":"Create Python virtual environment","category":"runtime","needs_user_input":false},{"name":"python-deps","title":"Install Python dependencies","category":"runtime","needs_user_input":false},{"name":"node-deps","title":"Install browser-tool dependencies","category":"runtime","needs_user_input":false},{"name":"opentui-engine","title":"Set up OpenTUI engine","category":"runtime","needs_user_input":false},{"name":"path","title":"Install hermes command","category":"runtime","needs_user_input":false},{"name":"config","title":"Prepare config and skills","category":"configuration","needs_user_input":false},{"name":"setup","title":"Configure API keys and settings","category":"configuration","needs_user_input":true},{"name":"gateway","title":"Configure gateway service","category":"configuration","needs_user_input":true},'"$desktop_stage"'{"name":"complete","title":"Finish install","category":"runtime","needs_user_input":false}]}'
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
@@ -1980,6 +1980,76 @@ install_node_deps() {
|
||||
restore_dirty_lockfiles "$INSTALL_DIR"
|
||||
}
|
||||
|
||||
# Provision the native OpenTUI engine on NODE 26.3+ (no Bun): `npm install` +
|
||||
# `npm run build` (esbuild → dist/main.js) in ui-opentui. The engine's
|
||||
# renderer loads via the experimental `node:ffi` API that only exists on Node
|
||||
# 26.3+. The launcher (hermes_cli/main.py:_opentui_available) only uses OpenTUI
|
||||
# when a Node >= 26.3 resolves AND the v2 package is built; otherwise it falls
|
||||
# back to the Ink engine. So this stage is STRICTLY best-effort: any failure
|
||||
# (unsupported platform, Node < 26.3, no network, install/build fails) logs a
|
||||
# warning and returns 0. A skipped OpenTUI setup just means the user gets Ink —
|
||||
# breaking the install would be far worse than skipping OpenTUI. Every sub-step
|
||||
# is guarded; this function never `exit`s and never returns non-zero.
|
||||
install_opentui() {
|
||||
# node:ffi isn't validated on Windows/Termux — keep those hosts on Ink.
|
||||
if [ "$OS" = "windows" ] || [ "$DISTRO" = "termux" ] || [ "$OS" = "android" ]; then
|
||||
log_info "Skipping OpenTUI engine (unsupported platform) — using Ink."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Only meaningful if the v2 package is present in this checkout.
|
||||
if [ ! -f "$INSTALL_DIR/ui-opentui/package.json" ]; then
|
||||
log_info "Skipping OpenTUI engine (ui-opentui not present) — using Ink."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Setting up OpenTUI engine (native TUI, Node 26.3+ / node:ffi)..."
|
||||
|
||||
# Resolve a Node >= 26.3.0 (the node:ffi floor): HERMES_NODE > node on PATH,
|
||||
# version-checked. We do NOT install Node here — if one new enough isn't
|
||||
# available the launcher cleanly falls back to Ink.
|
||||
local node_bin=""
|
||||
for cand in "${HERMES_NODE:-}" "$(command -v node 2>/dev/null || true)"; do
|
||||
[ -n "$cand" ] && [ -x "$cand" ] || continue
|
||||
if "$cand" -e 'const p=process.versions.node.split(".").map(Number); process.exit(p[0]>26||(p[0]===26&&p[1]>=3)?0:1)' 2>/dev/null; then
|
||||
node_bin="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$node_bin" ]; then
|
||||
log_warn "OpenTUI engine setup skipped (needs Node >= 26.3.0; none found) — using the Ink engine. Install Node 26.3+ or set HERMES_NODE."
|
||||
return 0
|
||||
fi
|
||||
log_success "Node found ($("$node_bin" --version 2>/dev/null || echo "unknown"))"
|
||||
|
||||
# npm ships with Node; the build (`node scripts/build.mjs`) runs fine on any
|
||||
# recent Node — only the runtime needs 26.3, which the launcher re-checks.
|
||||
local npm_bin
|
||||
npm_bin="$(command -v npm 2>/dev/null || true)"
|
||||
if [ -z "$npm_bin" ]; then
|
||||
log_warn "OpenTUI engine setup skipped (npm not found) — using the Ink engine."
|
||||
return 0
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR/ui-opentui" || { log_warn "OpenTUI engine setup skipped (cd failed) — using Ink."; return 0; }
|
||||
|
||||
# Pull deps (fetches the per-arch @opentui/core-<arch> native lib) then build
|
||||
# the Node bundle (dist/main.js). Both idempotent.
|
||||
log_info "Installing OpenTUI dependencies (npm install)..."
|
||||
if ! "$npm_bin" install --no-audit --no-fund >/dev/null 2>&1; then
|
||||
log_warn "OpenTUI engine setup skipped (npm install failed) — the Ink engine will be used."
|
||||
return 0
|
||||
fi
|
||||
log_info "Building OpenTUI engine (npm run build)..."
|
||||
if ! "$npm_bin" run build >/dev/null 2>&1; then
|
||||
log_warn "OpenTUI engine setup skipped (build failed) — the Ink engine will be used."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_success "OpenTUI engine ready (opt-in: HERMES_TUI_ENGINE=opentui; default is Ink)."
|
||||
return 0
|
||||
}
|
||||
|
||||
run_setup_wizard() {
|
||||
if [ "$RUN_SETUP" = false ]; then
|
||||
log_info "Skipping setup wizard (--skip-setup)"
|
||||
@@ -2636,6 +2706,12 @@ run_stage_body() {
|
||||
check_node
|
||||
install_node_deps
|
||||
;;
|
||||
opentui-engine)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
require_install_dir
|
||||
install_opentui
|
||||
;;
|
||||
path)
|
||||
detect_os
|
||||
resolve_install_layout
|
||||
@@ -2743,6 +2819,7 @@ main() {
|
||||
setup_venv
|
||||
install_deps
|
||||
install_node_deps
|
||||
install_opentui
|
||||
setup_path
|
||||
copy_config_templates
|
||||
run_setup_wizard
|
||||
|
||||
@@ -90,6 +90,7 @@ AUTHOR_MAP = {
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"evansrory@gmail.com": "zimigit2020",
|
||||
"237263164+ft-ioxcs@users.noreply.github.com": "ft-ioxcs",
|
||||
"tharushkadinujaya05@gmail.com": "0xneobyte",
|
||||
"138671361+Veritas-7@users.noreply.github.com": "Veritas-7",
|
||||
|
||||
@@ -1653,6 +1653,37 @@ class TestAuxiliaryFallbackLayering:
|
||||
exc.status_code = 402
|
||||
return exc
|
||||
|
||||
def test_auto_provider_uses_task_then_main_chain_before_builtin_chain(self, monkeypatch):
|
||||
"""Auto aux call failures try per-task then top-level fallback before built-ins."""
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create.side_effect = self._make_payment_err()
|
||||
|
||||
main_chain_client = MagicMock()
|
||||
main_chain_client.chat.completions.create.return_value = MagicMock(choices=[
|
||||
MagicMock(message=MagicMock(content="from main fallback chain"))
|
||||
])
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "qwen/qwen3.5-122b-a10b")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None)), \
|
||||
patch("agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(None, None, "")) as mock_task_chain, \
|
||||
patch("agent.auxiliary_client._try_main_fallback_chain",
|
||||
return_value=(main_chain_client, "inclusionai/ring-2.6-1t:free", "openrouter")) as mock_main_chain, \
|
||||
patch("agent.auxiliary_client._try_payment_fallback") as mock_builtin_chain:
|
||||
result = call_llm(
|
||||
task="title_generation",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert main_chain_client.chat.completions.create.called
|
||||
mock_task_chain.assert_called_once_with(
|
||||
"title_generation", "auto", reason="payment error")
|
||||
mock_main_chain.assert_called_once_with(
|
||||
"title_generation", "auto", reason="payment error")
|
||||
mock_builtin_chain.assert_not_called()
|
||||
|
||||
def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog):
|
||||
"""When a user has fallback_chain configured, it's tried BEFORE the main agent model."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
@@ -118,6 +118,64 @@ class TestResolveAutoMainFirst:
|
||||
assert client is chain_client
|
||||
assert model == "google/gemini-3-flash-preview"
|
||||
|
||||
def test_main_unavailable_uses_task_fallback_chain_before_builtin_chain(self):
|
||||
"""Auto aux resolution honors auxiliary.<task>.fallback_chain before built-ins."""
|
||||
task_client = MagicMock()
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nvidia",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="qwen/qwen3.5-122b-a10b",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client",
|
||||
return_value=(None, None), # main provider has no client
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(task_client, "task-free-model", "fallback_chain[0](openrouter)"),
|
||||
) as mock_task_chain, patch(
|
||||
"agent.auxiliary_client._try_main_fallback_chain",
|
||||
) as mock_main_chain, patch(
|
||||
"agent.auxiliary_client._try_openrouter",
|
||||
) as mock_openrouter:
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
client, model = _resolve_auto(task="title_generation")
|
||||
|
||||
assert client is task_client
|
||||
assert model == "task-free-model"
|
||||
mock_task_chain.assert_called_once_with(
|
||||
"title_generation", "nvidia", reason="main provider unavailable")
|
||||
mock_main_chain.assert_not_called()
|
||||
mock_openrouter.assert_not_called()
|
||||
|
||||
def test_main_unavailable_uses_main_fallback_chain_before_builtin_chain(self):
|
||||
"""Auto aux resolution honors top-level fallback_providers before built-ins."""
|
||||
main_fallback_client = MagicMock()
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nvidia",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="qwen/qwen3.5-122b-a10b",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client",
|
||||
return_value=(None, None), # main provider has no client
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(None, None, ""),
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_main_fallback_chain",
|
||||
return_value=(main_fallback_client, "inclusionai/ring-2.6-1t:free", "openrouter"),
|
||||
) as mock_main_chain, patch(
|
||||
"agent.auxiliary_client._try_openrouter",
|
||||
) as mock_openrouter:
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
client, model = _resolve_auto(task="title_generation")
|
||||
|
||||
assert client is main_fallback_client
|
||||
assert model == "inclusionai/ring-2.6-1t:free"
|
||||
mock_main_chain.assert_called_once_with(
|
||||
"title_generation", "nvidia", reason="main provider unavailable")
|
||||
mock_openrouter.assert_not_called()
|
||||
|
||||
def test_no_main_config_uses_chain_directly(self):
|
||||
"""No main provider configured → skip step 1, use chain (no regression)."""
|
||||
chain_client = MagicMock()
|
||||
|
||||
@@ -6,7 +6,10 @@ import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.run import _resolve_progress_thread_id
|
||||
from gateway.run import (
|
||||
_resolve_gateway_display_bool,
|
||||
_resolve_progress_thread_id,
|
||||
)
|
||||
|
||||
|
||||
class TestMattermostProgressThreadRouting:
|
||||
@@ -32,6 +35,97 @@ class TestMattermostProgressThreadRouting:
|
||||
) is None
|
||||
|
||||
|
||||
class TestMattermostDisplayHygiene:
|
||||
def test_mattermost_requires_platform_opt_in_for_interim_assistant_messages(self):
|
||||
"""Global interim commentary must not make Mattermost leak scratch notes."""
|
||||
user_config = {"display": {"interim_assistant_messages": True}}
|
||||
|
||||
assert _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
"mattermost",
|
||||
"interim_assistant_messages",
|
||||
default=True,
|
||||
platform=Platform.MATTERMOST,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
) is False
|
||||
|
||||
def test_mattermost_platform_opt_in_can_enable_interim_assistant_messages(self):
|
||||
"""Mattermost can still opt into commentary explicitly per platform."""
|
||||
user_config = {
|
||||
"display": {
|
||||
"interim_assistant_messages": False,
|
||||
"platforms": {
|
||||
"mattermost": {"interim_assistant_messages": True},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assert _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
"mattermost",
|
||||
"interim_assistant_messages",
|
||||
default=True,
|
||||
platform=Platform.MATTERMOST,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
) is True
|
||||
|
||||
def test_mattermost_requires_platform_opt_in_for_thinking_progress(self):
|
||||
"""Global thinking_progress must not surface internal analysis in Mattermost."""
|
||||
user_config = {"display": {"thinking_progress": True}}
|
||||
|
||||
assert _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
"mattermost",
|
||||
"thinking_progress",
|
||||
default=False,
|
||||
platform=Platform.MATTERMOST,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
) is False
|
||||
|
||||
def test_mattermost_requires_platform_opt_in_for_show_reasoning(self):
|
||||
"""Global show_reasoning must not prepend scratch reasoning in Mattermost."""
|
||||
user_config = {"display": {"show_reasoning": True}}
|
||||
|
||||
assert _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
"mattermost",
|
||||
"show_reasoning",
|
||||
default=False,
|
||||
platform=Platform.MATTERMOST,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
) is False
|
||||
|
||||
def test_mattermost_platform_opt_in_can_enable_show_reasoning(self):
|
||||
user_config = {
|
||||
"display": {
|
||||
"show_reasoning": False,
|
||||
"platforms": {"mattermost": {"show_reasoning": True}},
|
||||
}
|
||||
}
|
||||
|
||||
assert _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
"mattermost",
|
||||
"show_reasoning",
|
||||
default=False,
|
||||
platform=Platform.MATTERMOST,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
) is True
|
||||
|
||||
def test_global_thinking_progress_still_applies_to_other_platforms(self):
|
||||
"""The Mattermost guard must not silently neuter Telegram/other chats."""
|
||||
user_config = {"display": {"thinking_progress": True}}
|
||||
|
||||
assert _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
"telegram",
|
||||
"thinking_progress",
|
||||
default=False,
|
||||
platform=Platform.TELEGRAM,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform & Config
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -347,6 +441,24 @@ class TestMattermostSend:
|
||||
payload = self.adapter._api_post.call_args_list[0][0][1]
|
||||
assert payload["root_id"] == "root_post"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_send_with_invalid_thread_root_never_falls_back_flat(self):
|
||||
"""Tool/status/progress bubbles must stay quiet when the thread is broken."""
|
||||
self.adapter._reply_mode = "thread"
|
||||
self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""})
|
||||
self.adapter._api_post = AsyncMock(return_value={})
|
||||
|
||||
result = await self.adapter.send(
|
||||
"channel_1",
|
||||
"⚙️ terminal...",
|
||||
metadata={"thread_id": "bad_root"},
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert self.adapter._api_post.call_count == 1
|
||||
payload = self.adapter._api_post.call_args_list[0][0][1]
|
||||
assert payload["root_id"] == "bad_root"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_api_failure(self):
|
||||
"""When API returns error, send should return failure."""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Contract: media-send overrides must accept the ``metadata`` kwarg.
|
||||
|
||||
``BasePlatformAdapter.send_multiple_images`` passes ``metadata=metadata``
|
||||
to ``send_image`` / ``send_image_file`` / ``send_animation`` on every send.
|
||||
An override whose signature stops at ``reply_to`` raises ``TypeError:
|
||||
send_image() got an unexpected keyword argument 'metadata'`` at runtime —
|
||||
which is exactly how image delivery broke on WhatsApp and email.
|
||||
|
||||
This mirrors ``test_discord_media_metadata.py`` but covers the two
|
||||
adapters that previously slipped, plus a best-effort sweep over every
|
||||
adapter that imports cleanly so the next slip is caught at test time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _accepts_metadata(method) -> bool:
|
||||
params = inspect.signature(method).parameters
|
||||
if "metadata" in params:
|
||||
return True
|
||||
# A ``**kwargs`` catch-all also absorbs metadata (the convention used by
|
||||
# WhatsApp's send_video / send_voice / send_document overrides).
|
||||
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
||||
|
||||
|
||||
# (module, class) for the two adapters this fix targeted. These must import
|
||||
# in CI, so assert directly rather than skipping.
|
||||
@pytest.mark.parametrize(
|
||||
"module_name, class_name",
|
||||
[
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("gateway.platforms.email", "EmailAdapter"),
|
||||
],
|
||||
)
|
||||
def test_send_image_accepts_metadata(module_name, class_name):
|
||||
cls = getattr(importlib.import_module(module_name), class_name)
|
||||
assert _accepts_metadata(cls.send_image), (
|
||||
f"{class_name}.send_image must accept 'metadata' (or **kwargs) — "
|
||||
f"send_multiple_images passes it on every send"
|
||||
)
|
||||
|
||||
|
||||
# Best-effort sweep across all shipped adapters. Modules whose optional
|
||||
# platform SDK isn't installed are skipped; an adapter that imports but
|
||||
# whose override drops metadata is a hard failure.
|
||||
_ALL_ADAPTERS = [
|
||||
("gateway.platforms.bluebubbles", "BlueBubblesAdapter"),
|
||||
("gateway.platforms.dingtalk", "DingTalkAdapter"),
|
||||
("gateway.platforms.discord", "DiscordAdapter"),
|
||||
("gateway.platforms.email", "EmailAdapter"),
|
||||
("gateway.platforms.feishu", "FeishuAdapter"),
|
||||
("gateway.platforms.matrix", "MatrixAdapter"),
|
||||
("gateway.platforms.mattermost", "MattermostAdapter"),
|
||||
("gateway.platforms.signal", "SignalAdapter"),
|
||||
("gateway.platforms.slack", "SlackAdapter"),
|
||||
("gateway.platforms.telegram", "TelegramAdapter"),
|
||||
("gateway.platforms.wecom", "WeComAdapter"),
|
||||
("gateway.platforms.weixin", "WeixinAdapter"),
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module_name, class_name", _ALL_ADAPTERS)
|
||||
def test_all_adapters_send_image_metadata_sweep(module_name, class_name):
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception as exc: # optional platform dep not installed
|
||||
pytest.skip(f"{module_name} not importable: {exc}")
|
||||
cls = getattr(module, class_name, None)
|
||||
if cls is None or "send_image" not in cls.__dict__:
|
||||
pytest.skip(f"{class_name} has no send_image override")
|
||||
assert _accepts_metadata(cls.send_image), (
|
||||
f"{class_name}.send_image drops the 'metadata' kwarg"
|
||||
)
|
||||
@@ -106,6 +106,42 @@ class TestInitialReplyToId:
|
||||
assert call_kwargs["metadata"] == {**metadata, "expect_edits": True}
|
||||
assert metadata == {"thread_id": "omt_topic789"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_first_send_marks_metadata_notify_true(self):
|
||||
"""Final streaming sends should use the existing notify=True marker."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
metadata={"thread_id": "root_post_123"},
|
||||
initial_reply_to_id="reply_post_456",
|
||||
)
|
||||
|
||||
await consumer._send_or_edit("Final answer", finalize=True)
|
||||
|
||||
call_kwargs = adapter.send.call_args[1]
|
||||
metadata = call_kwargs["metadata"]
|
||||
assert metadata["thread_id"] == "root_post_123"
|
||||
assert metadata["notify"] is True
|
||||
assert "delivery_kind" not in metadata
|
||||
assert "allow_flat_fallback" not in metadata
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonfinal_first_send_does_not_mark_notify(self):
|
||||
"""Preview/interim streaming sends must not be notify-worthy."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter,
|
||||
"chat_123",
|
||||
metadata={"thread_id": "root_post_123"},
|
||||
initial_reply_to_id="reply_post_456",
|
||||
)
|
||||
|
||||
await consumer._send_or_edit("Preview", finalize=False)
|
||||
|
||||
metadata = adapter.send.call_args[1]["metadata"]
|
||||
assert metadata == {"thread_id": "root_post_123", "expect_edits": True}
|
||||
|
||||
|
||||
class TestOverflowFirstMessage:
|
||||
"""Verify thread routing is preserved when the first message overflows."""
|
||||
|
||||
@@ -134,7 +134,7 @@ async def test_stream_consumer_fallback_sends_tail_after_partial_overflow():
|
||||
|
||||
adapter.send.assert_awaited_once()
|
||||
assert adapter.send.await_args.kwargs["content"] == "world"
|
||||
assert adapter.send.await_args.kwargs["metadata"] == {"thread_id": "77"}
|
||||
assert adapter.send.await_args.kwargs["metadata"] == {"thread_id": "77", "notify": True}
|
||||
adapter.delete_message.assert_not_awaited()
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
|
||||
@@ -76,7 +76,7 @@ async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(tm
|
||||
adapter.send_document.assert_awaited_once_with(
|
||||
chat_id="chat-1",
|
||||
file_path=str(media_file),
|
||||
metadata=None,
|
||||
metadata={"notify": True},
|
||||
)
|
||||
adapter.send_voice.assert_not_awaited()
|
||||
|
||||
@@ -95,7 +95,7 @@ async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_
|
||||
adapter.send_document.assert_awaited_once_with(
|
||||
chat_id="chat-1",
|
||||
file_path=str(media_file),
|
||||
metadata=None,
|
||||
metadata={"notify": True},
|
||||
)
|
||||
adapter.send_voice.assert_not_awaited()
|
||||
|
||||
@@ -116,7 +116,7 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_
|
||||
adapter.send_voice.assert_awaited_once_with(
|
||||
chat_id="chat-1",
|
||||
audio_path=str(media_file),
|
||||
metadata=None,
|
||||
metadata={"notify": True},
|
||||
)
|
||||
adapter.send_document.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Node-26 resolution for the OpenTUI engine + the launch-cwd channel.
|
||||
|
||||
Regression coverage for two ways the local TUI silently fell back to Ink /
|
||||
showed the wrong directory:
|
||||
|
||||
1. fnm's active/default node was on an older line (v25) while a usable v26.3
|
||||
sat installed-but-inactive — ``_node26_bin_or_none`` only checked
|
||||
``HERMES_NODE`` + ``which node`` and so reported "no node 26" → OpenTUI
|
||||
unavailable → Ink fallback.
|
||||
2. ``TERMINAL_CWD`` (the gateway's launch-dir channel) was only exported in
|
||||
worktree mode, so a normal launch let the gateway auto-detect the engine's
|
||||
own package dir as the workspace.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
|
||||
def _fake_node(path, version: str) -> None:
|
||||
"""Write a stub `node` that prints `version` for `--version`."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f'#!/bin/sh\necho "{version}"\n')
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
class TestFnmNode26Discovery:
|
||||
def test_discovers_inactive_v26_when_default_is_older(self, tmp_path, monkeypatch):
|
||||
"""A v26.3 installed under fnm is found even when PATH `node` is v25."""
|
||||
fnm_dir = tmp_path / "fnm"
|
||||
for ver in ("24.11.0", "25.9.0", "26.3.0"):
|
||||
_fake_node(fnm_dir / "node-versions" / f"v{ver}" / "installation" / "bin" / "node", f"v{ver}")
|
||||
monkeypatch.setenv("FNM_DIR", str(fnm_dir))
|
||||
monkeypatch.delenv("HERMES_NODE", raising=False)
|
||||
# PATH node is the too-old default (v25).
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda _b: str(
|
||||
fnm_dir / "node-versions" / "v25.9.0" / "installation" / "bin" / "node"
|
||||
))
|
||||
|
||||
resolved = main_mod._node26_bin_or_none()
|
||||
assert resolved is not None
|
||||
assert "v26.3.0" in resolved # newest qualifying, not the v25 default
|
||||
|
||||
def test_candidates_sorted_newest_first(self, tmp_path, monkeypatch):
|
||||
fnm_dir = tmp_path / "fnm"
|
||||
for ver in ("26.1.0", "26.4.0", "25.0.0"):
|
||||
_fake_node(fnm_dir / "node-versions" / f"v{ver}" / "installation" / "bin" / "node", f"v{ver}")
|
||||
monkeypatch.setenv("FNM_DIR", str(fnm_dir))
|
||||
cands = main_mod._fnm_node26_candidates()
|
||||
# Directory-name order: 26.4 before 26.1 before 25.0.
|
||||
idx = [next(i for i, c in enumerate(cands) if f"v{v}" in c) for v in ("26.4.0", "26.1.0", "25.0.0")]
|
||||
assert idx == sorted(idx)
|
||||
|
||||
def test_no_fnm_dir_is_empty_not_error(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FNM_DIR", str(tmp_path / "does-not-exist"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-none"))
|
||||
monkeypatch.setattr(main_mod.Path, "home", classmethod(lambda cls: tmp_path / "home-none"))
|
||||
assert main_mod._fnm_node26_candidates() == []
|
||||
|
||||
def test_hermes_node_still_wins(self, tmp_path, monkeypatch):
|
||||
"""An explicit HERMES_NODE >= 26.3 takes precedence over fnm discovery."""
|
||||
explicit = tmp_path / "explicit" / "node"
|
||||
_fake_node(explicit, "v26.5.0")
|
||||
monkeypatch.setenv("HERMES_NODE", str(explicit))
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda _b: None)
|
||||
assert main_mod._node26_bin_or_none() == str(explicit)
|
||||
@@ -99,6 +99,70 @@ class TestResolveTuiHeapMb:
|
||||
assert self._resolve(64 * GB) == 8192
|
||||
|
||||
|
||||
class TestHeapOverride:
|
||||
"""HERMES_TUI_HEAP_MB env / display.tui_heap_mb config override (W1/D3).
|
||||
|
||||
The override REPLACES the 8192 default; the cgroup-fit clamp still applies on
|
||||
top so a too-high override can't exceed the container. Precedence: env > config.
|
||||
"""
|
||||
|
||||
def _resolve(self, limit_bytes, env=None, config_mb=None):
|
||||
with mock.patch.object(m, "_read_cgroup_memory_limit", return_value=limit_bytes), \
|
||||
mock.patch.object(m, "_config_tui_heap_mb_early", return_value=config_mb), \
|
||||
mock.patch.dict(m.os.environ, env or {}, clear=False):
|
||||
if env is None:
|
||||
m.os.environ.pop("HERMES_TUI_HEAP_MB", None)
|
||||
return m._resolve_tui_heap_mb()
|
||||
|
||||
def test_env_override_unconstrained(self):
|
||||
# explicit low cap, no cgroup limit -> used as-is (the low-mem opt-in).
|
||||
assert self._resolve(None, env={"HERMES_TUI_HEAP_MB": "256"}) == 256
|
||||
|
||||
def test_env_override_raises_ceiling(self):
|
||||
# a higher-than-default cap is honored when unconstrained.
|
||||
assert self._resolve(None, env={"HERMES_TUI_HEAP_MB": "16384"}) == 16384
|
||||
|
||||
def test_env_wins_over_config(self):
|
||||
assert self._resolve(None, env={"HERMES_TUI_HEAP_MB": "512"}, config_mb=4096) == 512
|
||||
|
||||
def test_config_used_when_no_env(self):
|
||||
assert self._resolve(None, config_mb=2048) == 2048
|
||||
|
||||
def test_override_still_cgroup_clamped(self):
|
||||
# user asks for 16GB but the container is 4GB -> trimmed to 75% = 3072.
|
||||
assert self._resolve(4 * GB, env={"HERMES_TUI_HEAP_MB": "16384"}) == 3072
|
||||
|
||||
def test_low_override_honored_under_big_container(self):
|
||||
# a deliberately low cap is NOT raised by a roomy container.
|
||||
assert self._resolve(16 * GB, env={"HERMES_TUI_HEAP_MB": "256"}) == 256
|
||||
|
||||
def test_garbage_env_falls_through_to_default(self):
|
||||
assert self._resolve(None, env={"HERMES_TUI_HEAP_MB": "nope"}) == 8192
|
||||
|
||||
def test_nonpositive_env_falls_through(self):
|
||||
assert self._resolve(None, env={"HERMES_TUI_HEAP_MB": "0"}) == 8192
|
||||
|
||||
|
||||
class TestExposeGcOnOpenTuiArgv:
|
||||
"""W1/D4: the OpenTUI engine argv must carry --expose-gc (parity with Ink) so
|
||||
global.gc() is a real call, not a no-op."""
|
||||
|
||||
def test_opentui_argv_has_expose_gc(self, tmp_path):
|
||||
app_dir = tmp_path / "ui-opentui"
|
||||
(app_dir / "src" / "entry").mkdir(parents=True)
|
||||
(app_dir / "src" / "entry" / "main.tsx").write_text("// entry")
|
||||
(app_dir / "node_modules" / "@opentui").mkdir(parents=True)
|
||||
(app_dir / "dist").mkdir()
|
||||
(app_dir / "dist" / "main.js").write_text("// built")
|
||||
with mock.patch.object(m, "PROJECT_ROOT", tmp_path), \
|
||||
mock.patch.object(m, "_node26_bin", return_value="/usr/bin/node"):
|
||||
argv, cwd = m._make_opentui_argv(tui_dev=False)
|
||||
assert "--expose-gc" in argv
|
||||
assert argv[0] == "/usr/bin/node"
|
||||
assert argv[-1].endswith("dist/main.js")
|
||||
assert cwd == app_dir
|
||||
|
||||
|
||||
class TestNodeOptionsTokenMerge:
|
||||
"""The _launch_tui token-merge block must add the sized cap unless the user
|
||||
already supplied one, and must preserve unrelated NODE_OPTIONS flags."""
|
||||
|
||||
@@ -14,6 +14,20 @@ def main_mod():
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_ink_engine(monkeypatch):
|
||||
"""These tests exercise the Ink/npm bootstrap inside ``_make_tui_argv``.
|
||||
|
||||
The dual-engine dispatch (``_resolve_tui_engine``) auto-selects the native
|
||||
OpenTUI engine whenever ``ui-opentui/dist`` is built in the repo, which
|
||||
would route ``_make_tui_argv`` away from the npm path under test. Pin the
|
||||
engine to ink, mirroring test_tui_resume_flow.py.
|
||||
"""
|
||||
import hermes_cli.main as m
|
||||
|
||||
monkeypatch.setattr(m, "_resolve_tui_engine", lambda: "ink")
|
||||
|
||||
|
||||
def _touch_ink(root: Path) -> None:
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
ink.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -118,6 +118,82 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod)
|
||||
assert captured["resume"] == "20260409_000000_aa11bb"
|
||||
|
||||
|
||||
def test_bare_resume_parses_to_picker_sentinel():
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat_parser = build_top_level_parser()
|
||||
|
||||
args = parser.parse_args(["--tui", "--resume"])
|
||||
assert args.resume is True
|
||||
|
||||
args = parser.parse_args(["--resume", "abc123"])
|
||||
assert args.resume == "abc123"
|
||||
|
||||
args = parser.parse_args(["chat", "--tui", "--resume"])
|
||||
assert args.resume is True
|
||||
|
||||
|
||||
def test_cmd_chat_tui_bare_resume_skips_resolution_and_launches_picker(
|
||||
monkeypatch, main_mod
|
||||
):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, **kwargs):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
def boom(_val):
|
||||
raise AssertionError("bare --resume must not hit name/id resolution")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", boom)
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(resume=True))
|
||||
|
||||
assert captured["resume"] is True
|
||||
|
||||
|
||||
def test_cmd_chat_bare_resume_without_tui_exits_with_guidance(
|
||||
monkeypatch, capsys, main_mod
|
||||
):
|
||||
monkeypatch.setattr(main_mod, "_resolve_use_tui", lambda args: False)
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"_launch_tui",
|
||||
lambda *a, **kw: pytest.fail("must not launch the TUI"),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_chat(_args(tui=False, resume=True))
|
||||
|
||||
assert exc.value.code == 2
|
||||
out = capsys.readouterr().out
|
||||
assert "requires the TUI" in out
|
||||
assert "hermes --tui --resume" in out
|
||||
|
||||
|
||||
def test_launch_tui_sets_picker_env_for_bare_resume(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setenv("HERMES_TUI_RESUME", "stale-missing-session")
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"_make_tui_argv",
|
||||
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_mod.subprocess,
|
||||
"call",
|
||||
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod._launch_tui(resume_session_id=True)
|
||||
|
||||
assert captured["env"]["HERMES_TUI_RESUME"] == "picker"
|
||||
|
||||
|
||||
def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
@@ -1008,6 +1084,10 @@ def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False)
|
||||
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}")
|
||||
# _make_tui_argv now dispatches on the TUI engine first; resolving "opentui"
|
||||
# availability probes `node --version` (a subprocess.run this test would
|
||||
# otherwise record). Pin the Ink engine — this test covers the Ink dev path.
|
||||
monkeypatch.setattr(main_mod, "_resolve_tui_engine", lambda: "ink")
|
||||
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -176,11 +176,16 @@ class TestClientCacheBoundedGrowth:
|
||||
"""When the loop changes, the old entry should be replaced, not duplicated."""
|
||||
from agent.auxiliary_client import (
|
||||
_client_cache,
|
||||
_client_cache_key,
|
||||
_client_cache_lock,
|
||||
_get_cached_client,
|
||||
)
|
||||
|
||||
key = ("test_replace", True, "", "", "", (), False, "")
|
||||
key = _client_cache_key(
|
||||
"test_replace",
|
||||
async_mode=True,
|
||||
task="",
|
||||
)
|
||||
|
||||
# Simulate a stale entry from a closed loop
|
||||
old_loop = asyncio.new_event_loop()
|
||||
|
||||
@@ -7,6 +7,7 @@ import time
|
||||
import types
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
@@ -380,6 +381,78 @@ def test_tui_verbose_tool_events_omit_details_when_redaction_fails(monkeypatch):
|
||||
assert "result_text" not in events[1][2]
|
||||
|
||||
|
||||
def test_tool_complete_emits_full_unified_diff(monkeypatch):
|
||||
events: list[tuple[str, str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
server._sessions,
|
||||
"diff-test",
|
||||
{"tool_progress_mode": "concise", "tool_started_at": {}, "edit_snapshots": {}},
|
||||
)
|
||||
|
||||
diff = "--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-a = 1\n+a = 2\n"
|
||||
result = json.dumps({"success": True, "diff": diff})
|
||||
server._on_tool_complete("diff-test", "tool-1", "patch", {"mode": "replace", "path": "x.py"}, result)
|
||||
|
||||
assert events and events[0][0] == "tool.complete"
|
||||
payload = events[0][2]
|
||||
# the raw unified diff rides alongside the pretty/capped inline_diff
|
||||
assert payload["diff_unified"] == diff
|
||||
assert "inline_diff" in payload
|
||||
|
||||
|
||||
def test_verbose_result_text_drops_diff_echo_when_diff_unified_ships(monkeypatch):
|
||||
# A tall edit's result JSON embeds the WHOLE diff; tail-capping that echo
|
||||
# yields an unparseable JSON-looking fragment the TUI can't suppress
|
||||
# reliably. When diff_unified ships, result_text must carry only the
|
||||
# non-diff signal — small, parseable, never the diff echo.
|
||||
events: list[tuple[str, str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
server._sessions,
|
||||
"diff-echo-test",
|
||||
{"tool_progress_mode": "verbose", "tool_started_at": {}, "edit_snapshots": {}},
|
||||
)
|
||||
|
||||
lines = "\n".join(f"+def fn_{i}() -> int: return {i}" for i in range(60))
|
||||
diff = f"--- a/x.py\n+++ b/x.py\n@@ -1,0 +1,60 @@\n{lines}\n"
|
||||
result = json.dumps(
|
||||
{"success": True, "diff": diff, "files_modified": ["x.py"], "_warning": "stale read"}
|
||||
)
|
||||
server._on_tool_complete("diff-echo-test", "tool-1", "patch", {"mode": "patch"}, result)
|
||||
|
||||
payload = events[0][2]
|
||||
assert payload["diff_unified"] == diff
|
||||
text = payload["result_text"]
|
||||
assert "[showing verbose tail" not in text # small enough to dodge the cap
|
||||
parsed = json.loads(text) # parseable …
|
||||
assert "diff" not in parsed # … with the echo gone
|
||||
assert parsed["_warning"] == "stale read" # non-diff signal survives
|
||||
# without diff_unified (non-edit tools) the result_text is untouched
|
||||
assert server._result_sans_diff_echo("plain text result") == "plain text result"
|
||||
assert server._result_sans_diff_echo('{"output": "x"}') == '{"output": "x"}'
|
||||
|
||||
|
||||
def test_cap_diff_unified_truncates_at_line_boundary():
|
||||
line = "+" + "x" * 63 # 64 bytes per line incl. newline
|
||||
diff = "\n".join([line] * 100)
|
||||
|
||||
capped = server._cap_diff_unified(diff, max_bytes=1000)
|
||||
|
||||
body, _, marker = capped.rpartition("\n")
|
||||
assert marker.startswith("# … diff truncated (")
|
||||
assert marker.endswith(" more bytes)")
|
||||
assert len(body.encode("utf-8")) <= 1000
|
||||
# cut on a line boundary: every surviving line is intact
|
||||
assert all(l == line for l in body.split("\n"))
|
||||
# under the cap → untouched
|
||||
assert server._cap_diff_unified("small", max_bytes=1000) == "small"
|
||||
|
||||
|
||||
def test_dispatch_rejects_non_object_request():
|
||||
resp = server.dispatch([])
|
||||
|
||||
@@ -4162,6 +4235,51 @@ def test_session_info_includes_mcp_servers(monkeypatch):
|
||||
assert info["mcp_servers"] == fake_status
|
||||
|
||||
|
||||
def test_session_info_includes_session_title(monkeypatch):
|
||||
"""session.info carries the live session title (window-title chrome).
|
||||
|
||||
Resolution order mirrors _session_live_title: DB row wins, a queued
|
||||
pending_title fills in before the row exists, "" until either lands.
|
||||
"""
|
||||
agent = types.SimpleNamespace(tools=[], model="m", provider="p")
|
||||
|
||||
# No session at all -> "" (and never a crash).
|
||||
assert server._session_info(agent, None)["title"] == ""
|
||||
|
||||
# pending_title before the DB row exists.
|
||||
session = {"session_key": "k1", "pending_title": "rename the moon"}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
assert server._session_info(agent, session)["title"] == "rename the moon"
|
||||
|
||||
# DB row wins over pending.
|
||||
fake_db = types.SimpleNamespace(get_session_title=lambda key: "db title")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: fake_db)
|
||||
assert server._session_info(agent, session)["title"] == "db title"
|
||||
|
||||
|
||||
def test_emit_title_refresh_pushes_session_info(monkeypatch):
|
||||
"""_emit_title_refresh emits a session.info for a live session and is a
|
||||
silent no-op for unknown/agent-less sessions (it runs on the auto-title
|
||||
worker thread -- it must never raise)."""
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent, session: {"title": "t"})
|
||||
|
||||
agent = types.SimpleNamespace(tools=[], model="m", provider="p")
|
||||
monkeypatch.setitem(server._sessions, "title-test", {"agent": agent, "session_key": "k"})
|
||||
|
||||
server._emit_title_refresh("title-test")
|
||||
assert events == [("session.info", "title-test", {"title": "t"})]
|
||||
|
||||
# Unknown sid / no agent -> no emission, no exception.
|
||||
server._emit_title_refresh("missing-sid")
|
||||
monkeypatch.setitem(server._sessions, "agentless", {"session_key": "k2"})
|
||||
server._emit_title_refresh("agentless")
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History-mutating commands must reject while session.running is True.
|
||||
# Without these guards, prompt.submit's post-run history write either
|
||||
@@ -4562,6 +4680,90 @@ def test_respond_unpacks_sid_tuple_correctly():
|
||||
server._answers.pop("rid-x", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocking prompts wait for the human (v6 north-star #5): _block with
|
||||
# timeout=None must never expire — interrupt/shutdown (_clear_pending)
|
||||
# are the only releases.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_block_in_thread(monkeypatch, sid):
|
||||
"""Start _block(timeout=None) on a background thread; return
|
||||
(thread, results, get_rid) where get_rid polls for the pending rid."""
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
|
||||
results: list[str] = []
|
||||
|
||||
def runner():
|
||||
results.append(server._block("clarify.request", sid, {"question": "q"}))
|
||||
|
||||
t = threading.Thread(target=runner, daemon=True)
|
||||
t.start()
|
||||
|
||||
def get_rid():
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
with server._prompt_lock:
|
||||
for rid, (owner, _ev) in server._pending.items():
|
||||
if owner == sid:
|
||||
return rid
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("pending rid never appeared for sid=%s" % sid)
|
||||
|
||||
return t, results, get_rid
|
||||
|
||||
|
||||
def test_block_no_timeout_waits_for_delayed_answer(monkeypatch):
|
||||
"""_block(timeout=None) must keep blocking until the answer arrives —
|
||||
no premature empty return."""
|
||||
t, results, get_rid = _run_block_in_thread(monkeypatch, "sid_block_wait")
|
||||
rid = get_rid()
|
||||
|
||||
# Answer after a short delay; _block must still be waiting.
|
||||
time.sleep(0.3)
|
||||
assert t.is_alive(), "_block returned before any answer was provided"
|
||||
with server._prompt_lock:
|
||||
server._answers[rid] = "green"
|
||||
server._pending[rid][1].set()
|
||||
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive()
|
||||
assert results == ["green"]
|
||||
|
||||
|
||||
def test_clear_pending_releases_no_timeout_block(monkeypatch):
|
||||
"""_clear_pending(sid) must release a timeout=None _block with ''."""
|
||||
t, results, get_rid = _run_block_in_thread(monkeypatch, "sid_block_clear")
|
||||
get_rid()
|
||||
|
||||
server._clear_pending("sid_block_clear")
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive()
|
||||
assert results == [""]
|
||||
|
||||
|
||||
def test_clear_pending_other_sid_does_not_release_block(monkeypatch):
|
||||
"""_clear_pending on an unrelated session must NOT release a pending
|
||||
timeout=None _block (session scoping)."""
|
||||
t, results, get_rid = _run_block_in_thread(monkeypatch, "sid_block_scoped")
|
||||
rid = get_rid()
|
||||
|
||||
server._clear_pending("sid_some_other_session")
|
||||
time.sleep(0.2)
|
||||
assert t.is_alive(), (
|
||||
"_clear_pending on another sid released a prompt owned by "
|
||||
"sid_block_scoped — session scoping is broken"
|
||||
)
|
||||
assert not results
|
||||
|
||||
# Clean up: release properly so the thread joins.
|
||||
with server._prompt_lock:
|
||||
server._answers[rid] = "done"
|
||||
server._pending[rid][1].set()
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive()
|
||||
assert results == ["done"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /model switch and other agent-mutating commands must reject while the
|
||||
# session is running. agent.switch_model() mutates self.model, self.provider,
|
||||
@@ -5776,6 +5978,351 @@ def test_session_most_recent_handles_db_unavailable(monkeypatch):
|
||||
assert resp["result"]["session_id"] is None
|
||||
|
||||
|
||||
# ── session.list (resume-picker filters + widened projection) ───────
|
||||
|
||||
|
||||
def _picker_row(sid, source, started, **extra):
|
||||
row = {
|
||||
"id": sid,
|
||||
"source": source,
|
||||
"title": f"title-{sid}",
|
||||
"preview": f"preview-{sid}",
|
||||
"started_at": started,
|
||||
"last_active": started,
|
||||
"message_count": 3,
|
||||
"ended_at": None,
|
||||
"cwd": None,
|
||||
"model": None,
|
||||
}
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
class _PickerDB:
|
||||
"""list_sessions_rich stand-in honouring the kwargs session.list maps."""
|
||||
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.calls = []
|
||||
|
||||
def list_sessions_rich(
|
||||
self,
|
||||
*,
|
||||
source=None,
|
||||
limit=20,
|
||||
offset=0,
|
||||
order_by_last_active=False,
|
||||
id_query=None,
|
||||
):
|
||||
self.calls.append(
|
||||
{
|
||||
"source": source,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"order_by_last_active": order_by_last_active,
|
||||
"id_query": id_query,
|
||||
}
|
||||
)
|
||||
rows = [dict(r) for r in self.rows]
|
||||
if source:
|
||||
rows = [r for r in rows if r.get("source") == source]
|
||||
if id_query:
|
||||
needle = id_query.strip().lower()
|
||||
rows = [r for r in rows if needle in r["id"].lower()]
|
||||
if order_by_last_active:
|
||||
rows.sort(key=lambda r: r.get("last_active") or 0, reverse=True)
|
||||
else:
|
||||
rows.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
|
||||
def _picker_db():
|
||||
return _PickerDB(
|
||||
[
|
||||
_picker_row(
|
||||
"tui-1",
|
||||
"tui",
|
||||
100,
|
||||
cwd="/home/u/proj",
|
||||
model="nous/hermes-4",
|
||||
ended_at=150.0,
|
||||
),
|
||||
_picker_row("cli-1", "cli", 90),
|
||||
_picker_row("cron-1", "cron", 80),
|
||||
_picker_row("cron-2", "cron", 70),
|
||||
_picker_row("tg-1", "telegram", 60),
|
||||
_picker_row("tool-1", "tool", 50),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_session_list_no_params_keeps_legacy_behavior_with_widened_rows(monkeypatch):
|
||||
db = _picker_db()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "session.list", "params": {}})
|
||||
|
||||
rows = resp["result"]["sessions"]
|
||||
# Legacy semantics: started_at DESC, `tool` denied, one DB fetch.
|
||||
assert [r["id"] for r in rows] == ["tui-1", "cli-1", "cron-1", "cron-2", "tg-1"]
|
||||
assert db.calls == [
|
||||
{
|
||||
"source": None,
|
||||
"limit": 400,
|
||||
"offset": 0,
|
||||
"order_by_last_active": False,
|
||||
"id_query": None,
|
||||
}
|
||||
]
|
||||
# Widened projection, None-safe for rows missing the new columns.
|
||||
first, second = rows[0], rows[1]
|
||||
assert first["cwd"] == "/home/u/proj"
|
||||
assert first["model"] == "nous/hermes-4"
|
||||
assert first["ended_at"] == 150.0
|
||||
assert first["last_active"] == 100
|
||||
assert second["cwd"] is None
|
||||
assert second["model"] is None
|
||||
assert second["ended_at"] is None
|
||||
assert second["last_active"] == 90
|
||||
# Legacy keys are still present and unchanged.
|
||||
assert second["title"] == "title-cli-1"
|
||||
assert second["preview"] == "preview-cli-1"
|
||||
assert second["message_count"] == 3
|
||||
assert second["source"] == "cli"
|
||||
|
||||
|
||||
def test_session_list_single_source_passes_through_to_db(monkeypatch):
|
||||
db = _picker_db()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.list", "params": {"sources": ["tui"]}}
|
||||
)
|
||||
|
||||
assert [r["id"] for r in resp["result"]["sessions"]] == ["tui-1"]
|
||||
assert db.calls[0]["source"] == "tui" # pushed into SQL, not Python-filtered
|
||||
|
||||
|
||||
def test_session_list_multi_source_filters_gateway_side(monkeypatch):
|
||||
db = _picker_db()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.list",
|
||||
"params": {"sources": ["cron", "telegram"]},
|
||||
}
|
||||
)
|
||||
|
||||
assert [r["id"] for r in resp["result"]["sessions"]] == [
|
||||
"cron-1",
|
||||
"cron-2",
|
||||
"tg-1",
|
||||
]
|
||||
assert db.calls[0]["source"] is None # multi-source: DB scan + gateway filter
|
||||
|
||||
|
||||
def test_session_list_sources_tool_stays_denied(monkeypatch):
|
||||
db = _picker_db()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.list", "params": {"sources": ["tool"]}}
|
||||
)
|
||||
|
||||
assert resp["result"]["sessions"] == []
|
||||
|
||||
|
||||
def test_session_list_query_maps_to_id_query_on_last_active_path(monkeypatch):
|
||||
db = _picker_db()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.list", "params": {"query": "cron"}}
|
||||
)
|
||||
|
||||
assert [r["id"] for r in resp["result"]["sessions"]] == ["cron-1", "cron-2"]
|
||||
assert db.calls[0]["id_query"] == "cron"
|
||||
assert db.calls[0]["order_by_last_active"] is True
|
||||
|
||||
|
||||
def test_session_list_offset_limit_paginate(monkeypatch):
|
||||
db = _picker_db()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
def page(offset, limit):
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.list",
|
||||
"params": {
|
||||
"sources": ["tui", "cli", "cron", "telegram"],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
},
|
||||
}
|
||||
)
|
||||
return [r["id"] for r in resp["result"]["sessions"]]
|
||||
|
||||
assert page(0, 2) == ["tui-1", "cli-1"]
|
||||
assert page(2, 2) == ["cron-1", "cron-2"]
|
||||
assert page(4, 2) == ["tg-1"]
|
||||
assert page(6, 2) == []
|
||||
|
||||
|
||||
# ── session.peek (resume-picker Space preview) ───────────────────────
|
||||
|
||||
|
||||
class _PeekDB:
|
||||
def __init__(self, session, messages):
|
||||
self.session = session
|
||||
self.messages = messages
|
||||
|
||||
def get_session(self, session_id):
|
||||
if self.session and session_id == self.session["id"]:
|
||||
return dict(self.session)
|
||||
return None
|
||||
|
||||
def get_messages(self, session_id):
|
||||
return [dict(m) for m in self.messages]
|
||||
|
||||
|
||||
def _peek_db():
|
||||
session = {
|
||||
"id": "sess-1",
|
||||
"title": "picker demo",
|
||||
"source": "tui",
|
||||
"model": "nous/hermes-4",
|
||||
"cwd": "/home/u/proj",
|
||||
"started_at": 100.0,
|
||||
"ended_at": 400.0,
|
||||
"end_reason": "tui_shutdown",
|
||||
"message_count": 6,
|
||||
"actual_cost_usd": None,
|
||||
"estimated_cost_usd": 0.42,
|
||||
}
|
||||
messages = [
|
||||
{"id": 1, "role": "system", "content": "sys prompt", "timestamp": 100.0},
|
||||
{"id": 2, "role": "user", "content": "first prompt", "timestamp": 110.0},
|
||||
{"id": 3, "role": "assistant", "content": "first answer", "timestamp": 120.0},
|
||||
{"id": 4, "role": "tool", "content": "tool output", "timestamp": 130.0},
|
||||
{"id": 5, "role": "user", "content": "second prompt", "timestamp": 140.0},
|
||||
{"id": 6, "role": "assistant", "content": "final answer", "timestamp": 150.0},
|
||||
]
|
||||
return _PeekDB(session, messages)
|
||||
|
||||
|
||||
def test_session_peek_returns_metadata_and_head_tail(monkeypatch):
|
||||
monkeypatch.setattr(server, "_get_db", _peek_db)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.peek",
|
||||
"params": {"session_id": "sess-1", "head": 1, "tail": 2},
|
||||
}
|
||||
)
|
||||
|
||||
result = resp["result"]
|
||||
meta = result["session"]
|
||||
assert meta == {
|
||||
"id": "sess-1",
|
||||
"title": "picker demo",
|
||||
"source": "tui",
|
||||
"model": "nous/hermes-4",
|
||||
"cwd": "/home/u/proj",
|
||||
"started_at": 100.0,
|
||||
"ended_at": 400.0,
|
||||
"end_reason": "tui_shutdown",
|
||||
"message_count": 6,
|
||||
"last_active": 150.0, # last message timestamp, not ended_at
|
||||
"cost_usd": 0.42, # estimated fallback when actual is None
|
||||
}
|
||||
# Only displayable (user/assistant) messages, system/tool rows skipped.
|
||||
assert [(m["role"], m["content"]) for m in result["head"]] == [
|
||||
("user", "first prompt")
|
||||
]
|
||||
assert [(m["role"], m["content"]) for m in result["tail"]] == [
|
||||
("user", "second prompt"),
|
||||
("assistant", "final answer"),
|
||||
]
|
||||
assert result["total_messages"] == 4
|
||||
assert all(m["truncated"] is False for m in result["head"] + result["tail"])
|
||||
|
||||
|
||||
def test_session_peek_head_tail_never_overlap(monkeypatch):
|
||||
db = _peek_db()
|
||||
db.messages = db.messages[:3] # system + user + assistant
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.peek",
|
||||
"params": {"session_id": "sess-1", "head": 2, "tail": 2},
|
||||
}
|
||||
)
|
||||
|
||||
result = resp["result"]
|
||||
head_ids = [m["id"] for m in result["head"]]
|
||||
tail_ids = [m["id"] for m in result["tail"]]
|
||||
assert head_ids == [2, 3]
|
||||
assert tail_ids == [] # both displayable rows already consumed by head
|
||||
assert result["total_messages"] == 2
|
||||
|
||||
|
||||
def test_session_peek_does_not_build_an_agent(monkeypatch):
|
||||
spawned = []
|
||||
monkeypatch.setattr(server, "_get_db", _peek_db)
|
||||
monkeypatch.setattr(
|
||||
server, "_make_agent", lambda *a, **kw: spawned.append("make") or None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_start_agent_build", lambda *a, **kw: spawned.append("build")
|
||||
)
|
||||
before_sessions = dict(server._sessions)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.peek", "params": {"session_id": "sess-1"}}
|
||||
)
|
||||
|
||||
assert "result" in resp
|
||||
assert spawned == []
|
||||
assert server._sessions == before_sessions # no live session registered
|
||||
|
||||
|
||||
def test_session_peek_unknown_id_is_clean_error(monkeypatch):
|
||||
monkeypatch.setattr(server, "_get_db", _peek_db)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.peek", "params": {"session_id": "nope"}}
|
||||
)
|
||||
|
||||
assert resp["error"]["code"] == 4007
|
||||
assert resp["error"]["message"] == "session not found"
|
||||
|
||||
|
||||
def test_session_peek_requires_session_id(monkeypatch):
|
||||
monkeypatch.setattr(server, "_get_db", _peek_db)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "session.peek", "params": {}})
|
||||
|
||||
assert resp["error"]["code"] == 4006
|
||||
|
||||
|
||||
def test_session_peek_db_unavailable(monkeypatch):
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
monkeypatch.setattr(server, "_db_error", "locked")
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.peek", "params": {"session_id": "sess-1"}}
|
||||
)
|
||||
|
||||
assert resp["error"]["code"] == 5046
|
||||
assert "state.db unavailable" in resp["error"]["message"]
|
||||
|
||||
|
||||
# ── browser.manage ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -7223,6 +7770,84 @@ def test_sniff_image_ext_magic_and_filename():
|
||||
assert server._sniff_image_ext(b"\x89PNG", "photo.jpeg") == ".jpeg"
|
||||
|
||||
|
||||
def test_tool_complete_derives_error_from_result_convention(monkeypatch):
|
||||
# The repo convention (agent.display._result_succeeded): a JSON-object
|
||||
# result with a truthy string "error" — or success:false — IS a failure.
|
||||
# The gateway surfaces it as payload["error"] so clients (OpenTUI ✗ state,
|
||||
# Ink trail ✗) don't sniff the convention themselves.
|
||||
events: list[tuple[str, str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
server._sessions,
|
||||
"err-test",
|
||||
{"tool_progress_mode": "verbose", "tool_started_at": {}, "edit_snapshots": {}},
|
||||
)
|
||||
|
||||
# 1) error-string result → flattened, capped error on the payload
|
||||
server._on_tool_complete(
|
||||
"err-test", "t1", "read_file", {"path": "/nope"},
|
||||
json.dumps({"error": "File not found:\n /nope"}),
|
||||
)
|
||||
assert events[-1][2]["error"] == "File not found: /nope"
|
||||
|
||||
# 2) success:false without error string → generic failure marker
|
||||
server._on_tool_complete(
|
||||
"err-test", "t2", "patch", {"path": "x"}, json.dumps({"success": False})
|
||||
)
|
||||
assert events[-1][2]["error"] == "tool reported failure"
|
||||
|
||||
# 3) plain-text result → NEVER a failure
|
||||
server._on_tool_complete("err-test", "t3", "terminal", {"command": "ls"}, "file-a\nfile-b")
|
||||
assert "error" not in events[-1][2]
|
||||
|
||||
# 4) JSON result with error: null / no error key → success
|
||||
server._on_tool_complete(
|
||||
"err-test", "t4", "web_search", {"q": "x"},
|
||||
json.dumps({"results": [1, 2], "error": None}),
|
||||
)
|
||||
assert "error" not in events[-1][2]
|
||||
|
||||
|
||||
def test_session_list_reports_scan_cap_truncation(monkeypatch):
|
||||
# The bounded multi-source scan must say so when the 10k safety cap stops
|
||||
# it with the requested window unfilled — an empty page past the cap is
|
||||
# "truncated", not "no more sessions".
|
||||
class _DenyAllDB:
|
||||
def __init__(self):
|
||||
self.offsets = []
|
||||
|
||||
def list_sessions_rich(self, source=None, limit=20, offset=0, **kw):
|
||||
self.offsets.append(offset)
|
||||
return [
|
||||
{"id": f"s{offset}-{i}", "source": "tool", "title": "", "preview": "",
|
||||
"started_at": 1, "message_count": 1}
|
||||
for i in range(limit)
|
||||
]
|
||||
|
||||
db = _DenyAllDB()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.list", "params": {"sources": ["tui", "cli"], "limit": 5}}
|
||||
)
|
||||
result = resp["result"]
|
||||
assert result["sessions"] == []
|
||||
assert result["truncated"] is True
|
||||
assert max(db.offsets) <= 10_000
|
||||
|
||||
|
||||
def test_session_list_truncated_false_on_normal_paths(monkeypatch):
|
||||
db = _picker_db()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
legacy = server.handle_request({"id": "1", "method": "session.list", "params": {}})
|
||||
assert legacy["result"]["truncated"] is False
|
||||
filtered = server.handle_request(
|
||||
{"id": "2", "method": "session.list", "params": {"sources": ["tui"], "limit": 5}}
|
||||
)
|
||||
assert filtered["result"]["truncated"] is False
|
||||
|
||||
|
||||
def test_slash_worker_close_reaps_zombie_and_closes_fds():
|
||||
"""A hung worker is SIGKILLed, the zombie reaped, all pipes closed — once."""
|
||||
calls = {k: 0 for k in ("terminate", "kill", "wait", "stdin", "stdout", "stderr")}
|
||||
@@ -7497,3 +8122,51 @@ def test_reap_idle_sessions_closes_only_evictable(monkeypatch):
|
||||
assert closed == [("stale", "idle_timeout")]
|
||||
finally:
|
||||
server._sessions.clear()
|
||||
|
||||
|
||||
class TestProcessCompletionCard:
|
||||
"""_emit_process_completion_card surfaces a background-process completion to
|
||||
the TUI as a notification.show card (Option B, glitch 2026-06-14) — in
|
||||
addition to the agent turn the completion triggers."""
|
||||
|
||||
@staticmethod
|
||||
def _capture(monkeypatch):
|
||||
emitted: list = []
|
||||
monkeypatch.setattr(server, "_emit", lambda event, sid, payload=None: emitted.append((event, sid, payload)))
|
||||
return emitted
|
||||
|
||||
def test_completion_exit_zero_is_an_info_card(self, monkeypatch):
|
||||
emitted = self._capture(monkeypatch)
|
||||
server._emit_process_completion_card(
|
||||
"s1", {"type": "completion", "session_id": "proc_1", "command": "sleep 20 && echo hi", "exit_code": 0}
|
||||
)
|
||||
assert len(emitted) == 1
|
||||
event, sid, payload = emitted[0]
|
||||
assert event == "notification.show"
|
||||
assert sid == "s1"
|
||||
assert payload["text"] == "sleep 20 && echo hi exited 0"
|
||||
assert payload["level"] == "info"
|
||||
assert payload["kind"] == "process.complete"
|
||||
assert payload["key"] == "proc:proc_1"
|
||||
|
||||
def test_nonzero_exit_is_a_warn_card(self, monkeypatch):
|
||||
emitted = self._capture(monkeypatch)
|
||||
server._emit_process_completion_card("s1", {"type": "completion", "command": "build", "exit_code": 1, "session_id": "p2"})
|
||||
assert emitted[0][2]["level"] == "warn"
|
||||
assert emitted[0][2]["text"] == "build exited 1"
|
||||
|
||||
def test_watch_match_is_not_carded(self, monkeypatch):
|
||||
emitted = self._capture(monkeypatch)
|
||||
server._emit_process_completion_card("s1", {"type": "watch_match", "command": "tail -f log"})
|
||||
assert emitted == []
|
||||
|
||||
def test_long_command_is_truncated(self, monkeypatch):
|
||||
emitted = self._capture(monkeypatch)
|
||||
server._emit_process_completion_card("s1", {"type": "completion", "command": "x" * 100, "exit_code": 0, "session_id": "p3"})
|
||||
assert "…" in emitted[0][2]["text"]
|
||||
assert len(emitted[0][2]["text"]) < 80
|
||||
|
||||
def test_missing_exit_code_says_finished(self, monkeypatch):
|
||||
emitted = self._capture(monkeypatch)
|
||||
server._emit_process_completion_card("s1", {"type": "completion", "command": "daemon", "session_id": "p4"})
|
||||
assert emitted[0][2]["text"] == "daemon finished"
|
||||
|
||||
+481
-23
@@ -176,6 +176,12 @@ _LONG_HANDLERS = frozenset(
|
||||
{
|
||||
"browser.manage",
|
||||
"cli.exec",
|
||||
# model.options is network-bound (pricing fetch + Nous tier check via
|
||||
# build_models_payload, ~seconds). The native TUI prefetches it right
|
||||
# after session.create; on the main thread that stalls every fast-path
|
||||
# RPC — notably complete.slash, so the first `/` dropdown after launch
|
||||
# took seconds to paint.
|
||||
"model.options",
|
||||
"plugins.manage",
|
||||
"session.branch",
|
||||
"session.compress",
|
||||
@@ -1351,7 +1357,11 @@ def _enable_gateway_prompts() -> None:
|
||||
# ── Blocking prompt factory ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
||||
def _block(event: str, sid: str, payload: dict, timeout: int | None = None) -> str:
|
||||
# Blocking prompts wait for the human; interrupt/shutdown (via
|
||||
# _clear_pending) are the only releases — v6 north-star #5. A
|
||||
# timeout would orphan the TUI prompt and silently feed the agent
|
||||
# an empty answer, so callers default to waiting forever.
|
||||
rid = uuid.uuid4().hex[:8]
|
||||
ev = threading.Event()
|
||||
with _prompt_lock:
|
||||
@@ -1402,6 +1412,11 @@ def resolve_skin() -> dict:
|
||||
"banner_hero": skin.banner_hero,
|
||||
"tool_prefix": skin.tool_prefix,
|
||||
"help_header": (skin.branding or {}).get("help_header", ""),
|
||||
# Native engines (Ink + OpenTUI) can now consume these too: spinner
|
||||
# animation data (faces/verbs/wings) and per-tool emoji overrides.
|
||||
# Additive + optional — old engines ignore unknown keys.
|
||||
"spinner": skin.spinner or {},
|
||||
"tool_emojis": skin.tool_emojis or {},
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -2410,6 +2425,17 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
||||
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off"
|
||||
except Exception:
|
||||
yolo = False
|
||||
# Session title (DB row, falling back to a not-yet-applied pending_title).
|
||||
# Drives client window-title chrome (OSC 0/2 in the native TUI); "" until
|
||||
# the first exchange titles the session.
|
||||
title = ""
|
||||
if session is not None:
|
||||
try:
|
||||
title = _session_live_title(
|
||||
session, str(session.get("session_key") or "")
|
||||
)
|
||||
except Exception:
|
||||
title = ""
|
||||
info: dict = {
|
||||
"model": getattr(agent, "model", ""),
|
||||
"provider": getattr(agent, "provider", ""),
|
||||
@@ -2422,6 +2448,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
||||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"personality": str(personality or ""),
|
||||
"title": title,
|
||||
"running": bool((session or {}).get("running")),
|
||||
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
||||
"version": "",
|
||||
@@ -2534,6 +2561,53 @@ def _cap_tui_verbose_text(text: str) -> str:
|
||||
return f"{label}{tail}"
|
||||
|
||||
|
||||
# The FULL raw unified diff shipped on file-edit tool.complete (`diff_unified`,
|
||||
# for clients with a native diff renderer — ui-opentui). Unlike the verbose
|
||||
# trail above this is rendered COLLAPSED by default and only on edit tools, so
|
||||
# it gets a far larger budget; 512KB is still a hard ceiling so a runaway
|
||||
# multi-megabyte edit can't flood the pipe.
|
||||
_DIFF_UNIFIED_MAX_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _cap_diff_unified(diff: str, max_bytes: int = _DIFF_UNIFIED_MAX_BYTES) -> str:
|
||||
raw = diff.encode("utf-8")
|
||||
if len(raw) <= max_bytes:
|
||||
return diff
|
||||
head = raw[:max_bytes].decode("utf-8", errors="ignore")
|
||||
# Truncate at a line boundary so the surviving diff stays parseable, then
|
||||
# append an honest marker line (never send more than the cap + marker).
|
||||
cut = head.rfind("\n")
|
||||
if cut > 0:
|
||||
head = head[:cut]
|
||||
omitted = len(raw) - len(head.encode("utf-8"))
|
||||
return f"{head}\n# … diff truncated ({omitted} more bytes)"
|
||||
|
||||
|
||||
def _result_sans_diff_echo(result: str) -> str:
|
||||
"""The file-edit result JSON minus its `diff` echo.
|
||||
|
||||
Used for verbose `result_text` when the FULL diff already ships as
|
||||
`diff_unified`: a multi-KB diff echo inside the result JSON gets
|
||||
tail-capped by `_cap_tui_verbose_text` into an unparseable JSON-looking
|
||||
fragment that a client can neither render nor reliably suppress. The
|
||||
native renderer shows the real diff, so result_text should carry only the
|
||||
non-diff signal (success/files_modified/warnings/lsp_diagnostics).
|
||||
Returns `result` unchanged when it isn't a JSON object with a `diff` key.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(result)
|
||||
except Exception:
|
||||
return result
|
||||
if not isinstance(data, dict) or "diff" not in data:
|
||||
return result
|
||||
try:
|
||||
return json.dumps(
|
||||
{k: v for k, v in data.items() if k != "diff"}, ensure_ascii=False
|
||||
)
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
|
||||
def _redact_tui_verbose_text(text: str) -> str:
|
||||
try:
|
||||
from agent.redact import redact_sensitive_text
|
||||
@@ -2637,8 +2711,37 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
|
||||
_emit("tool.start", sid, payload)
|
||||
|
||||
|
||||
def _tool_error_from_result(result: str) -> str | None:
|
||||
"""Derive a tool-failure message from the repo's result convention.
|
||||
|
||||
Canon (same as ``agent.display._result_succeeded``): a JSON-object result
|
||||
with a truthy string ``error`` key — or an explicit ``success: false`` —
|
||||
means the tool failed. Returned flattened + capped for a one-line header.
|
||||
Conservative on purpose: non-JSON / non-dict results are NEVER failures
|
||||
(plain-text output is normal success output).
|
||||
"""
|
||||
try:
|
||||
data = json.loads(result)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
err = data.get("error")
|
||||
if isinstance(err, str) and err.strip():
|
||||
return " ".join(err.split())[:300]
|
||||
if data.get("success") is False:
|
||||
return "tool reported failure"
|
||||
return None
|
||||
|
||||
|
||||
def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str):
|
||||
payload = {"tool_id": tool_call_id, "name": name, "args": args}
|
||||
# Failure surfaced explicitly so clients don't have to sniff the result
|
||||
# convention themselves (the TUI's ✗ state and Ink's trail ✗ both key off
|
||||
# payload["error"], which was previously never set on this path).
|
||||
_tool_err = _tool_error_from_result(result)
|
||||
if _tool_err:
|
||||
payload["error"] = _tool_err
|
||||
session = _sessions.get(sid)
|
||||
snapshot = None
|
||||
started_at = None
|
||||
@@ -2655,10 +2758,6 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
|
||||
summary = _tool_summary(name, result, duration_s)
|
||||
if summary:
|
||||
payload["summary"] = summary
|
||||
if _session_verbose(sid):
|
||||
result_text = _tool_result_text(result)
|
||||
if result_text:
|
||||
payload["result_text"] = result_text
|
||||
if name == "todo":
|
||||
try:
|
||||
data = json.loads(result)
|
||||
@@ -2680,7 +2779,32 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
|
||||
payload["inline_diff"] = "\n".join(rendered)
|
||||
except Exception:
|
||||
pass
|
||||
if _tool_progress_enabled(sid) or payload.get("inline_diff"):
|
||||
# Alongside the pretty-rendered/capped `inline_diff` (Ink consumes that), ship
|
||||
# the RAW unified diff for clients with a native diff renderer (ui-opentui's
|
||||
# file-tool view). Capped at _DIFF_UNIFIED_MAX_BYTES with an honest marker.
|
||||
try:
|
||||
from agent.display import extract_edit_diff
|
||||
|
||||
diff_unified = extract_edit_diff(
|
||||
name,
|
||||
result,
|
||||
function_args=args,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
if diff_unified:
|
||||
payload["diff_unified"] = _cap_diff_unified(diff_unified)
|
||||
except Exception:
|
||||
pass
|
||||
if _session_verbose(sid):
|
||||
# Computed AFTER diff_unified: when the full diff ships natively, the
|
||||
# result_text drops the in-JSON diff echo (it would tail-cap into
|
||||
# unparseable JSON-looking noise under the client's rendered diff).
|
||||
result_text = _tool_result_text(
|
||||
_result_sans_diff_echo(result) if payload.get("diff_unified") else result
|
||||
)
|
||||
if result_text:
|
||||
payload["result_text"] = result_text
|
||||
if _tool_progress_enabled(sid) or payload.get("inline_diff") or payload.get("diff_unified"):
|
||||
_emit("tool.complete", sid, payload)
|
||||
|
||||
|
||||
@@ -2910,7 +3034,7 @@ def _wire_callbacks(sid: str):
|
||||
from tools.terminal_tool import set_sudo_password_callback
|
||||
from tools.skills_tool import set_secret_capture_callback
|
||||
|
||||
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120))
|
||||
set_sudo_password_callback(lambda: _block("sudo.request", sid, {}))
|
||||
|
||||
def secret_cb(env_var, prompt, metadata=None):
|
||||
pl = {"prompt": prompt, "env_var": env_var}
|
||||
@@ -3696,7 +3820,13 @@ def _coerce_message_text(content: Any) -> str:
|
||||
return str(content)
|
||||
|
||||
|
||||
def _history_to_messages(history: list[dict]) -> list[dict]:
|
||||
def _history_to_messages(history: list[dict], include_tool_output: bool = False) -> list[dict]:
|
||||
# ``include_tool_output`` (opt-in; only the native/opentui engine passes it via
|
||||
# session.resume) folds each tool's redacted+capped result + args into its row so
|
||||
# a resumed transcript renders collapsible tool blocks identical to a live turn.
|
||||
# OFF by default so the Ink path is byte-for-byte unchanged (its render tree showed
|
||||
# the verbose trail expanded and OOM'd on big output — #34095; the native engine
|
||||
# renders tools collapsed, so shipping the same capped tail is safe there).
|
||||
messages = []
|
||||
tool_call_args = {}
|
||||
|
||||
@@ -3724,9 +3854,13 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
|
||||
tc_info = tool_call_args.get(tc_id) if tc_id else None
|
||||
name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool"
|
||||
args = (tc_info[1] if tc_info else None) or {}
|
||||
messages.append(
|
||||
{"role": "tool", "name": name, "context": _tool_ctx(name, args)}
|
||||
)
|
||||
tool_msg = {"role": "tool", "name": name, "context": _tool_ctx(name, args)}
|
||||
if include_tool_output:
|
||||
if args:
|
||||
tool_msg["args"] = args
|
||||
if content_text.strip():
|
||||
tool_msg["result_text"] = _redact_tui_verbose_text(content_text)
|
||||
messages.append(tool_msg)
|
||||
continue
|
||||
# An assistant turn may carry only reasoning/thinking content with no
|
||||
# visible text (extended-thinking turns, thinking-only recovery
|
||||
@@ -3966,6 +4100,23 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
@method("session.list")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""List stored sessions for the resume picker / sidebar.
|
||||
|
||||
Optional params — omitting all of them keeps the legacy behaviour
|
||||
(most-recently-started first, ``tool`` rows denied, default limit 200):
|
||||
|
||||
- ``sources``: list of source tags to include (e.g. ``["cli", "tui"]``).
|
||||
Powers the picker's tab strip. The ``tool`` deny-list still applies
|
||||
on top. A single-element list is pushed into SQL
|
||||
(``list_sessions_rich(source=...)``); multi-element lists are
|
||||
filtered gateway-side over a bounded scan (the DB layer only takes a
|
||||
single ``source`` string).
|
||||
- ``query``: case-insensitive *session-id* substring filter, mapped to
|
||||
``list_sessions_rich(id_query=...)``. The DB applies ``id_query``
|
||||
only on its order-by-last-active path, so query results are ordered
|
||||
by most recent activity. Title/preview search stays client-side.
|
||||
- ``offset`` / ``limit``: pagination over the filtered list.
|
||||
"""
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5006)
|
||||
@@ -3980,16 +4131,76 @@ def _(rid, params: dict) -> dict:
|
||||
# platform is added or a user names their own source.
|
||||
deny = frozenset({"tool"})
|
||||
|
||||
limit = int(params.get("limit", 200) or 200)
|
||||
# Over-fetch modestly so per-source filtering doesn't leave us
|
||||
# short; the compression-tip projection in ``list_sessions_rich``
|
||||
# can also merge rows.
|
||||
fetch_limit = max(limit * 2, 200)
|
||||
rows = [
|
||||
s
|
||||
for s in db.list_sessions_rich(source=None, limit=fetch_limit)
|
||||
if (s.get("source") or "").strip().lower() not in deny
|
||||
][:limit]
|
||||
try:
|
||||
limit = max(1, int(params.get("limit", 200) or 200))
|
||||
except (TypeError, ValueError):
|
||||
limit = 200
|
||||
try:
|
||||
offset = max(0, int(params.get("offset", 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
query = str(params.get("query") or "").strip()
|
||||
raw_sources = params.get("sources")
|
||||
sources: list = []
|
||||
if isinstance(raw_sources, (list, tuple)):
|
||||
sources = [
|
||||
str(s).strip().lower() for s in raw_sources if str(s).strip()
|
||||
]
|
||||
|
||||
if not sources and not query and offset == 0:
|
||||
# Legacy path (no filter params) — byte-for-byte today's
|
||||
# behaviour. Over-fetch modestly so per-source filtering doesn't
|
||||
# leave us short; the compression-tip projection in
|
||||
# ``list_sessions_rich`` can also merge rows.
|
||||
fetch_limit = max(limit * 2, 200)
|
||||
rows = [
|
||||
s
|
||||
for s in db.list_sessions_rich(source=None, limit=fetch_limit)
|
||||
if (s.get("source") or "").strip().lower() not in deny
|
||||
][:limit]
|
||||
list_truncated = False
|
||||
else:
|
||||
# Filtered/paginated path. Single source pushes into SQL; the
|
||||
# deny-list and multi-source filter run gateway-side, so keep
|
||||
# scanning DB pages until the requested window is full (bounded
|
||||
# by a generous safety cap so a pathological DB can't pin us).
|
||||
source_arg = sources[0] if len(sources) == 1 else None
|
||||
allowed = frozenset(sources) if sources else None
|
||||
wanted = offset + limit
|
||||
|
||||
def _eligible(row: dict) -> bool:
|
||||
src = (row.get("source") or "").strip().lower()
|
||||
if src in deny:
|
||||
return False
|
||||
return allowed is None or src in allowed
|
||||
|
||||
collected: list = []
|
||||
db_offset = 0
|
||||
page = max(wanted * 2, 200)
|
||||
scan_capped = False
|
||||
while len(collected) < wanted:
|
||||
if db_offset >= 10_000:
|
||||
# Safety cap hit with the window still unfilled — report it
|
||||
# honestly (``truncated``) so the client can say "results
|
||||
# truncated" instead of silently serving an empty page.
|
||||
scan_capped = True
|
||||
break
|
||||
batch = db.list_sessions_rich(
|
||||
source=source_arg,
|
||||
limit=page,
|
||||
offset=db_offset,
|
||||
# ``id_query`` only applies on the order-by-last-active
|
||||
# path; keep legacy started_at ordering when unfiltered.
|
||||
order_by_last_active=bool(query),
|
||||
id_query=query or None,
|
||||
)
|
||||
collected.extend(r for r in batch if _eligible(r))
|
||||
if len(batch) < page:
|
||||
break
|
||||
db_offset += page
|
||||
rows = collected[offset : offset + limit]
|
||||
list_truncated = scan_capped and len(collected) < wanted
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
@@ -4001,15 +4212,133 @@ def _(rid, params: dict) -> dict:
|
||||
"started_at": s.get("started_at") or 0,
|
||||
"message_count": s.get("message_count") or 0,
|
||||
"source": s.get("source") or "",
|
||||
# Picker row metadata (None-safe; older rows may
|
||||
# predate these columns).
|
||||
"cwd": s.get("cwd") or None,
|
||||
"last_active": s.get("last_active")
|
||||
or s.get("started_at")
|
||||
or 0,
|
||||
"ended_at": s.get("ended_at"),
|
||||
"model": s.get("model") or None,
|
||||
}
|
||||
for s in rows
|
||||
]
|
||||
],
|
||||
# True when the bounded multi-source scan hit its safety cap
|
||||
# before filling the requested window — the client should show
|
||||
# "results truncated" instead of treating the page as final.
|
||||
"truncated": list_truncated,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5006, str(e))
|
||||
|
||||
|
||||
@method("session.peek")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""DB-only preview of a stored session for the resume picker.
|
||||
|
||||
``{session_id, head?, tail?}`` → session metadata plus the first ``head``
|
||||
and last ``tail`` displayable messages (``user``/``assistant`` rows with
|
||||
non-empty text content; tool spam and empty tool-call carriers are
|
||||
skipped). Purely a read: no agent is constructed, no live session state
|
||||
is created or switched — this powers the picker's Space preview, which
|
||||
must stay cheap while the user scrolls.
|
||||
|
||||
Response shape::
|
||||
|
||||
{
|
||||
"session": {id, title, source, model, cwd, started_at, ended_at,
|
||||
end_reason, message_count, last_active, cost_usd},
|
||||
"head": [{id, role, content, truncated, timestamp}, ...],
|
||||
"tail": [{...}], # never overlaps head
|
||||
"total_messages": <int> # displayable (user/assistant) count
|
||||
}
|
||||
"""
|
||||
target = str(params.get("session_id") or "").strip()
|
||||
if not target:
|
||||
return _err(rid, 4006, "session_id required")
|
||||
try:
|
||||
head = max(0, int(params.get("head", 2) or 0))
|
||||
except (TypeError, ValueError):
|
||||
head = 2
|
||||
try:
|
||||
tail = max(0, int(params.get("tail", 2) or 0))
|
||||
except (TypeError, ValueError):
|
||||
tail = 2
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5046)
|
||||
try:
|
||||
row = db.get_session(target)
|
||||
if not row:
|
||||
return _err(rid, 4007, "session not found")
|
||||
|
||||
msgs = db.get_messages(target)
|
||||
last_active = (
|
||||
(msgs[-1].get("timestamp") if msgs else None)
|
||||
or row.get("started_at")
|
||||
or 0
|
||||
)
|
||||
|
||||
def _displayable(m: dict) -> bool:
|
||||
if (m.get("role") or "") not in ("user", "assistant"):
|
||||
return False
|
||||
content = m.get("content")
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
return bool(content) # multimodal parts list
|
||||
|
||||
def _peek_msg(m: dict) -> dict:
|
||||
content = m.get("content")
|
||||
if not isinstance(content, str):
|
||||
try:
|
||||
content = json.dumps(content, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
content = str(content)
|
||||
content = content or ""
|
||||
return {
|
||||
"id": m.get("id"),
|
||||
"role": m.get("role") or "",
|
||||
"content": content[:2000],
|
||||
"truncated": len(content) > 2000,
|
||||
"timestamp": m.get("timestamp"),
|
||||
}
|
||||
|
||||
display = [m for m in msgs if _displayable(m)]
|
||||
head_msgs = display[:head] if head else []
|
||||
# Slice the remainder so head and tail never overlap, even when
|
||||
# head + tail >= len(display).
|
||||
tail_msgs = display[head:][-tail:] if tail else []
|
||||
|
||||
cost = row.get("actual_cost_usd")
|
||||
if cost is None:
|
||||
cost = row.get("estimated_cost_usd")
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"session": {
|
||||
"id": row["id"],
|
||||
"title": row.get("title") or "",
|
||||
"source": row.get("source") or "",
|
||||
"model": row.get("model") or None,
|
||||
"cwd": row.get("cwd") or None,
|
||||
"started_at": row.get("started_at") or 0,
|
||||
"ended_at": row.get("ended_at"),
|
||||
"end_reason": row.get("end_reason"),
|
||||
"message_count": row.get("message_count") or 0,
|
||||
"last_active": last_active,
|
||||
"cost_usd": cost,
|
||||
},
|
||||
"head": [_peek_msg(m) for m in head_msgs],
|
||||
"tail": [_peek_msg(m) for m in tail_msgs],
|
||||
"total_messages": len(display),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5046, str(e))
|
||||
|
||||
|
||||
@method("session.most_recent")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Return the most recent human-facing session id, or ``None``.
|
||||
@@ -4230,7 +4559,9 @@ def _(rid, params: dict) -> dict:
|
||||
display_history_prefix = display_history[
|
||||
: max(0, len(display_history) - len(history))
|
||||
]
|
||||
messages = _history_to_messages(display_history)
|
||||
messages = _history_to_messages(
|
||||
display_history, include_tool_output=bool(params.get("with_tool_output"))
|
||||
)
|
||||
tokens = _set_session_context(target)
|
||||
try:
|
||||
# Pass the profile's db so the agent persists turns to the right
|
||||
@@ -4397,6 +4728,22 @@ def _session_live_title(session: dict, key: str) -> str:
|
||||
return title
|
||||
|
||||
|
||||
def _emit_title_refresh(sid: str) -> None:
|
||||
"""Push a session.info refresh after a title change (pending-title
|
||||
application, auto-title landing, or a session.title rename) so clients'
|
||||
window-title chrome updates immediately. Thread-safe (auto-title calls
|
||||
this from its worker thread; _emit serializes on the stdout lock).
|
||||
Never raises."""
|
||||
try:
|
||||
session = _sessions.get(sid)
|
||||
agent = (session or {}).get("agent")
|
||||
if session is None or agent is None:
|
||||
return
|
||||
_emit("session.info", sid, _session_info(agent, session))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict:
|
||||
key = str(session.get("session_key") or sid)
|
||||
agent = session.get("agent")
|
||||
@@ -4622,15 +4969,18 @@ def _(rid, params: dict) -> dict:
|
||||
title = (params.get("title", "") or "").strip()
|
||||
if not title:
|
||||
return _err(rid, 4021, "title required")
|
||||
sid = str(params.get("session_id") or "")
|
||||
try:
|
||||
if db.set_session_title(key, title):
|
||||
session["pending_title"] = None
|
||||
_emit_title_refresh(sid)
|
||||
return _ok(rid, {"pending": False, "title": title})
|
||||
# rowcount == 0 can mean "same value" as well as "missing row".
|
||||
# Queue only when the session row truly does not exist yet.
|
||||
existing_row = db.get_session(key)
|
||||
if existing_row:
|
||||
session["pending_title"] = None
|
||||
_emit_title_refresh(sid)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
@@ -4639,6 +4989,7 @@ def _(rid, params: dict) -> dict:
|
||||
},
|
||||
)
|
||||
session["pending_title"] = title
|
||||
_emit_title_refresh(sid)
|
||||
return _ok(rid, {"pending": True, "title": title})
|
||||
except ValueError as e:
|
||||
return _err(rid, 4022, str(e))
|
||||
@@ -5603,6 +5954,39 @@ def _notification_event_dedup_key(evt: dict) -> tuple:
|
||||
return (evt_sid, evt_type)
|
||||
|
||||
|
||||
def _emit_process_completion_card(sid: str, evt: dict) -> None:
|
||||
"""Surface a background-process COMPLETION to the TUI as a notification card,
|
||||
in ADDITION to the agent turn it triggers. A bare `notify_on_complete` exit
|
||||
otherwise reaches the TUI only as the agent's narration (the completion is fed
|
||||
to the model as a synthetic prompt, never sent to the UI). Emitting a
|
||||
``notification.show`` lets the OpenTUI engine render a distinct inline card so
|
||||
the user actually sees the process finish; the Ink engine treats it as a
|
||||
notice. Additive — no existing behaviour changes. Completion events only
|
||||
(watch matches aren't terminal); the dedup at the call sites ensures one card
|
||||
per completion. (glitch 2026-06-14)"""
|
||||
if evt.get("type", "completion") != "completion":
|
||||
return
|
||||
cmd = str(evt.get("command") or "process").strip().replace("\n", " ")
|
||||
if len(cmd) > 60:
|
||||
cmd = cmd[:59] + "…"
|
||||
code = evt.get("exit_code")
|
||||
if code is None:
|
||||
text, level = f"{cmd} finished", "info"
|
||||
else:
|
||||
text = f"{cmd} exited {code}"
|
||||
level = "info" if code == 0 else "warn"
|
||||
_emit(
|
||||
"notification.show",
|
||||
sid,
|
||||
{
|
||||
"text": text,
|
||||
"kind": "process.complete",
|
||||
"level": level,
|
||||
"key": f"proc:{evt.get('session_id', '')}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _notification_poller_loop(
|
||||
stop_event: threading.Event, sid: str, session: dict
|
||||
) -> None:
|
||||
@@ -5661,6 +6045,7 @@ def _notification_poller_loop(
|
||||
|
||||
rid = f"__notif__{int(time.time() * 1000)}"
|
||||
try:
|
||||
_emit_process_completion_card(sid, evt)
|
||||
_emit("message.start", sid)
|
||||
_run_prompt_submit(rid, sid, session, text)
|
||||
except Exception as exc:
|
||||
@@ -5704,6 +6089,7 @@ def _notification_poller_loop(
|
||||
|
||||
rid = f"__notif__{int(time.time() * 1000)}"
|
||||
try:
|
||||
_emit_process_completion_card(sid, evt)
|
||||
_emit("message.start", sid)
|
||||
_run_prompt_submit(rid, sid, session, text)
|
||||
except Exception as exc:
|
||||
@@ -6039,10 +6425,20 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
||||
text,
|
||||
raw,
|
||||
session.get("history", []),
|
||||
# Auto-title lands on a background thread — refresh
|
||||
# session.info when it does so clients' window-title
|
||||
# chrome (OSC 0/2) updates without waiting for the
|
||||
# next turn. _emit is stdout-lock-guarded (thread-safe).
|
||||
title_callback=lambda _title: _emit_title_refresh(sid),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# The pending title (applied synchronously above) is visible NOW —
|
||||
# refresh session.info so window-title chrome picks it up.
|
||||
if status == "complete" and _pending:
|
||||
_emit_title_refresh(sid)
|
||||
|
||||
# CLI parity: when voice-mode TTS is on, speak the agent reply
|
||||
# (cli.py:_voice_speak_response). Only the final text — tool
|
||||
# calls / reasoning already stream separately and would be
|
||||
@@ -6135,6 +6531,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
||||
break
|
||||
session["running"] = True
|
||||
try:
|
||||
_emit_process_completion_card(sid, _evt)
|
||||
_emit("message.start", sid)
|
||||
_run_prompt_submit(rid, sid, session, synth)
|
||||
except Exception as _n_exc:
|
||||
@@ -9930,6 +10327,67 @@ def _(rid, params: dict) -> dict:
|
||||
return _err(rid, 5031, str(e))
|
||||
|
||||
|
||||
@method("startup.catalog")
|
||||
def _(rid, params: dict) -> dict:
|
||||
# Aggregate tools / skills / MCP servers for the native engine's startup panel
|
||||
# (item 9). Opt-in RPC — only the opentui home screen calls it, so the Ink path
|
||||
# is untouched. Each section is best-effort: a failing source yields an empty
|
||||
# section rather than erroring the whole call.
|
||||
tools: dict = {"total": 0, "toolsets": []}
|
||||
try:
|
||||
from toolsets import get_all_toolsets, get_toolset_info
|
||||
|
||||
# enabled toolsets for THIS session (or the config default), mirroring tools.list
|
||||
session = _sessions.get(params.get("session_id", ""))
|
||||
enabled = (
|
||||
set(getattr(session["agent"], "enabled_toolsets", []) or [])
|
||||
if session
|
||||
else set(_load_enabled_toolsets() or [])
|
||||
)
|
||||
for name in sorted(get_all_toolsets().keys()):
|
||||
info = get_toolset_info(name)
|
||||
if not info:
|
||||
continue
|
||||
is_on = name in enabled if enabled else True
|
||||
# the startup panel lists ENABLED toolsets with their tools (Ink parity)
|
||||
tool_names = [str(t) for t in (info.get("resolved_tools") or [])]
|
||||
tools["toolsets"].append(
|
||||
{"name": name, "count": int(info["tool_count"]), "enabled": is_on, "tools": tool_names}
|
||||
)
|
||||
if is_on:
|
||||
tools["total"] += int(info["tool_count"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
skills: dict = {"total": 0, "categories": []}
|
||||
try:
|
||||
from hermes_cli.banner import get_available_skills
|
||||
|
||||
by_cat = get_available_skills() or {}
|
||||
for cat in sorted(by_cat.keys()):
|
||||
names = by_cat[cat] or []
|
||||
skills["categories"].append({"name": cat, "count": len(names)})
|
||||
skills["total"] += len(names)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mcp_servers: list = []
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
raw_cfg = read_raw_config() or {}
|
||||
servers = raw_cfg.get("mcp_servers")
|
||||
if isinstance(servers, dict):
|
||||
for name, cfg in servers.items():
|
||||
if isinstance(cfg, dict) and _parse_enabled_flag(cfg.get("enabled", True), default=True):
|
||||
mcp_servers.append(str(name))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _ok(rid, {"tools": tools, "skills": skills, "mcp": {"servers": sorted(mcp_servers)}})
|
||||
|
||||
|
||||
@method("tools.show")
|
||||
def _(rid, params: dict) -> dict:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.repos/
|
||||
*.frame.txt
|
||||
*.ansi
|
||||
bun.lockb
|
||||
|
||||
# the global ~/.gitignore_global `lib/` rule swallows our test harness — re-include it
|
||||
!src/test/lib/
|
||||
.bench/
|
||||
@@ -0,0 +1 @@
|
||||
26.3
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# ui-opentui — native OpenTUI engine for Hermes
|
||||
|
||||
Solid + `@opentui/core` over Node FFI. Ink (`ui-tui/`) is the shipping default;
|
||||
this is the experimental engine (draft PR #42922).
|
||||
|
||||
## Node 26 setup (required; will not touch your other projects)
|
||||
|
||||
This package needs **Node ≥ 26.3** (`--experimental-ffi` floor). Everything
|
||||
else on this machine/repo can keep whatever Node it already uses — pin 26 to
|
||||
this directory only:
|
||||
|
||||
```sh
|
||||
# 1. install fnm (skip if you have it; nvm/mise work too — see below)
|
||||
curl -fsSL https://fnm.vercel.app/install | bash
|
||||
# add to ~/.zshrc (or bashrc): eval "$(fnm env --use-on-cd --shell zsh)"
|
||||
|
||||
# 2. install Node 26 SIDE BY SIDE (does NOT change your default)
|
||||
fnm install 26
|
||||
|
||||
# 3. done — this directory has a .node-version (26.3), so `cd ui-opentui`
|
||||
# auto-switches to 26 and leaving switches back. Do NOT run `fnm default 26`.
|
||||
node -v # v26.x here; your old version everywhere else
|
||||
```
|
||||
|
||||
No shell integration wanted (CI, scripts, one-off): `fnm exec --using 26 -- node ...`
|
||||
or invoke the absolute binary (`~/.local/share/fnm/node-versions/v26.*/installation/bin/node`).
|
||||
mise users: `mise use node@26` in this directory. nvm users: `nvm install 26`,
|
||||
plus an `.nvmrc` shim (`echo 26 > .nvmrc`) if you rely on auto-switching.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- **Native modules are ABI-locked.** A `node_modules` installed under Node
|
||||
20/22 will not load under 26 (and vice versa) — run `npm ci` (or
|
||||
`npm rebuild`) after switching versions. Same applies to the **tui-bench** repo's node-pty (`github.com/NousResearch/tui-bench`).
|
||||
- **Global npm packages don't follow** between versions (per-version prefix);
|
||||
reinstall the few you need, or don't use globals.
|
||||
- **Editor terminals** (Zed/VS Code) need the `fnm env` line in your shell rc;
|
||||
the `.node-version` auto-switch then covers any shell that cd's here.
|
||||
- **Never run this package with bun** — the FFI seam and the Solid/JSX build
|
||||
are Node-path only here.
|
||||
- `package.json` declares `engines.node >= 26.3`, so a wrong-Node `npm ci`
|
||||
warns immediately.
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
node scripts/build.mjs
|
||||
HERMES_TUI_MOUSE=1 node --experimental-ffi --no-warnings dist/main.js
|
||||
```
|
||||
|
||||
Gates: `npm run check` (typecheck + lint + tests). Memory/perf benchmarks live
|
||||
in the **tui-bench** repo (`github.com/NousResearch/tui-bench`; see its README). Transcript windowing (memory architecture) is
|
||||
documented in `../docs/plans/opentui-transcript-windowing.md`.
|
||||
@@ -0,0 +1,103 @@
|
||||
import js from "@eslint/js"
|
||||
import tseslint from "typescript-eslint"
|
||||
import unusedImports from "eslint-plugin-unused-imports"
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// .bench/ and .demo/ are build artifacts (bench `nodes` cell and the
|
||||
// smoke demo: `node scripts/build.mjs scripts/demo.tsx .demo`) — never lint.
|
||||
ignores: ["node_modules/**", "dist/**", ".bench/**", ".demo/**", ".repos/**", "*.frame.txt", "*.ansi"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
},
|
||||
rules: {
|
||||
// Boundary code bans these; the Solid view follows TS-strict but is not Effect.
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"error",
|
||||
{ vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_" },
|
||||
],
|
||||
|
||||
// --- Type-aware, high-value: ON as ERROR ---
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
|
||||
// --- Type-safety: ENFORCED as errors in our boundary/logic .ts code ---
|
||||
// Production .ts is clean of the no-unsafe-* family (the loose-typed gateway
|
||||
// payloads are Schema-decoded). The only sources are (a) *.tsx — @opentui/solid's
|
||||
// JSX namespace types every component `return (<…>)` as `error`/unknown, a
|
||||
// framework limitation disabled for views below — and (b) the test harness
|
||||
// (loose render/effect fixtures + async mocks), exempt below. So we enforce ERROR.
|
||||
"@typescript-eslint/no-unsafe-assignment": "error",
|
||||
"@typescript-eslint/no-unsafe-member-access": "error",
|
||||
"@typescript-eslint/no-unsafe-argument": "error",
|
||||
"@typescript-eslint/no-unsafe-return": "error",
|
||||
"@typescript-eslint/no-unsafe-call": "error",
|
||||
"@typescript-eslint/no-base-to-string": "error",
|
||||
"@typescript-eslint/restrict-template-expressions": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"@typescript-eslint/require-await": "error",
|
||||
// Defensive guards on untrusted runtime/gateway data: TS's narrowing doesn't
|
||||
// model the wire, so "condition is always truthy" here is intentional armor,
|
||||
// not dead code. Kept as a hint (warn), not a gate failure.
|
||||
"@typescript-eslint/no-unnecessary-condition": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
// @opentui/solid's custom JSX namespace types component returns as `error`/
|
||||
// unknown, so EVERY `return (<…>)` in a view trips the no-unsafe-* family.
|
||||
// That's a framework typing limitation, not unsafe app code — off for views.
|
||||
files: ["**/*.tsx"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Test helpers/fixtures: keep `!` on known-present data, and allow the loose
|
||||
// render/effect harness casts + async mock signatures (they satisfy real
|
||||
// Promise-returning interfaces with no body to await).
|
||||
files: ["**/*.test.ts", "**/*.test.tsx", "src/test/lib/**"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Build/config scripts (the eslint flat config, the esbuild build.mjs, the
|
||||
// vitest config) are not part of the typed TS program, so the project service
|
||||
// can't type them — disable type-aware linting there to avoid parser errors,
|
||||
// and declare the Node globals they use (process, console, URL).
|
||||
files: ["**/*.mjs", "*.config.ts"],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
languageOptions: {
|
||||
...tseslint.configs.disableTypeChecked.languageOptions,
|
||||
globals: { process: "readonly", console: "readonly", URL: "readonly", URLSearchParams: "readonly" },
|
||||
},
|
||||
},
|
||||
)
|
||||
Generated
+4919
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@hermes/ui-opentui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=26.3"
|
||||
},
|
||||
"description": "Native OpenTUI engine for Hermes (Solid + Effect-at-boundary, from scratch). Ink (ui-tui/) stays the shipping default.",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"fmt": "prettier --write src",
|
||||
"fix": "prettier --write src && eslint . --fix",
|
||||
"build": "node scripts/build.mjs",
|
||||
"start": "node --experimental-ffi --no-warnings dist/main.js",
|
||||
"test": "vitest run",
|
||||
"check": "bash scripts/check.sh",
|
||||
"dev": "node scripts/build.mjs && node --experimental-ffi --no-warnings dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.4.1",
|
||||
"@opentui/keymap": "0.4.1",
|
||||
"@opentui/solid": "0.4.1",
|
||||
"effect": "4.0.0-beta.78",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"solid-js": "1.9.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/preset-typescript": "^7.29.7",
|
||||
"@effect/vitest": "^4.0.0-beta.78",
|
||||
"@eslint/js": "^9",
|
||||
"@types/node": "^24",
|
||||
"babel-preset-solid": "^1.9.12",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-unused-imports": "^4",
|
||||
"prettier": "^3",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Single acceptance command for the Bun→Node-26 switchover (see
|
||||
# docs/plans/opentui-node26-build-spec.md). Proves, on a Node 26.3 host, that the
|
||||
# OpenTUI v2 engine runs WITHOUT Bun and at parity:
|
||||
#
|
||||
# 1. Node >= 26.3 present (the node:ffi floor); reports whether bun is on PATH
|
||||
# (the engine must NOT need it).
|
||||
# 2. `npm run check` — prettier + tsc + eslint + vitest (151+), all on Node.
|
||||
# 3. live-gateway transport smoke — spawns the real Python tui_gateway via the
|
||||
# node:child_process client, asserts gateway.ready + session.create.
|
||||
# (Skipped if no Hermes venv resolves — CI parity.)
|
||||
# 4. selection/markdown smoke in a real tmux TTY — asserts the native <markdown>
|
||||
# (Tree-sitter) PAINTS under node --experimental-ffi and that a selection
|
||||
# copies the RAW markdown source. (Skipped if tmux is unavailable.)
|
||||
#
|
||||
# Run: cd ui-opentui && HERMES_PYTHON_SRC_ROOT=<checkout-root> bash scripts/acceptance.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Absolute node, so a fresh tmux pane (which won't inherit our PATH / fnm shim)
|
||||
# runs the SAME Node 26.3, not the shell's default.
|
||||
NODE_BIN="$(command -v node || echo node)"
|
||||
|
||||
pass=0; fail=0; skip=0
|
||||
ok() { echo " ✅ $1"; pass=$((pass+1)); }
|
||||
bad() { echo " ❌ $1"; fail=$((fail+1)); }
|
||||
note() { echo " ⏭ $1"; skip=$((skip+1)); }
|
||||
|
||||
echo "== [1/4] runtime: Node >= 26.3, Bun-free =="
|
||||
NODE_V="$(node -p 'process.versions.node' 2>/dev/null || echo 0.0.0)"
|
||||
node -e 'const [a,b]=process.versions.node.split(".").map(Number); process.exit(a>26||(a===26&&b>=3)?0:1)' \
|
||||
&& ok "node $NODE_V (>= 26.3)" || bad "node $NODE_V is below the 26.3 node:ffi floor"
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
note "bun is on PATH ($(command -v bun)) — fine; the engine does not use it (proven below)"
|
||||
else
|
||||
ok "no bun on PATH — single-runtime host"
|
||||
fi
|
||||
|
||||
echo "== [2/4] check: prettier + tsc + eslint + vitest =="
|
||||
if bash scripts/check.sh >/tmp/accept-check.log 2>&1; then ok "check green ($(grep -c 'passed' /tmp/accept-check.log >/dev/null 2>&1; grep -oE '[0-9]+ passed' /tmp/accept-check.log | tail -1))"
|
||||
else bad "check failed — see /tmp/accept-check.log"; tail -20 /tmp/accept-check.log; fi
|
||||
|
||||
echo "== [3/4] live-gateway transport smoke (real Python gateway, no Bun) =="
|
||||
if [ -n "${HERMES_PYTHON_SRC_ROOT:-}" ] || [ -x "../.venv/bin/python" ]; then
|
||||
rm -rf .accept && node scripts/build.mjs src/test/liveGateway.smoke.ts .accept >/dev/null 2>&1
|
||||
OUT="$(node --experimental-ffi --no-warnings .accept/liveGateway.smoke.js 2>&1)"
|
||||
echo "$OUT" | grep -q "^PASS" && ok "$(echo "$OUT" | grep '^PASS')" || { echo "$OUT" | grep -qE "TRANSPORT ERROR|SKIP" && note "gateway smoke skipped (no python/model)" || bad "gateway smoke: $(echo "$OUT" | head -1)"; }
|
||||
rm -rf .accept
|
||||
else
|
||||
note "no HERMES_PYTHON_SRC_ROOT / venv — gateway smoke skipped"
|
||||
fi
|
||||
|
||||
echo "== [4/4] selection/markdown smoke in a real tmux TTY (tree-sitter under FFI) =="
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
rm -rf .accept && node scripts/build.mjs src/test/selectionCopy.smoke.tsx .accept >/dev/null 2>&1
|
||||
rm -f /tmp/accept-sel.json
|
||||
S="accept-$$"
|
||||
tmux kill-session -t "$S" 2>/dev/null
|
||||
tmux new-session -d -s "$S" -x 120 -y 40
|
||||
tmux send-keys -t "$S" "SEL_SMOKE_OUT=/tmp/accept-sel.json $NODE_BIN --experimental-ffi --no-warnings $PWD/.accept/selectionCopy.smoke.js; tmux wait-for -S $S" Enter
|
||||
tmux wait-for "$S" 2>/dev/null || sleep 6
|
||||
tmux kill-session -t "$S" 2>/dev/null
|
||||
if node -e 'process.exit(require("/tmp/accept-sel.json").pass===true?0:1)' 2>/dev/null; then
|
||||
ok "markdown painted + selection copied source (tree-sitter under node FFI)"
|
||||
else
|
||||
bad "selection/markdown smoke failed — see /tmp/accept-sel.json"; cat /tmp/accept-sel.json 2>/dev/null
|
||||
fi
|
||||
rm -rf .accept
|
||||
else
|
||||
note "tmux not available — markdown smoke skipped (run it on a TTY host)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== acceptance: $pass passed, $fail failed, $skip skipped =="
|
||||
[ "$fail" -eq 0 ] && { echo "ACCEPTANCE: PASS"; exit 0; } || { echo "ACCEPTANCE: FAIL"; exit 1; }
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Build the OpenTUI v2 Solid app for Node 26 (no Bun).
|
||||
*
|
||||
* Mirrors OpenTUI's own Node recipe (`~/github/opentui/.../run-node26.mjs` +
|
||||
* `packages/solid/scripts/solid-transform.ts`): apply babel-preset-solid in
|
||||
* `generate:"universal"` mode with `moduleName:"@opentui/solid"` to every app
|
||||
* .tsx/.jsx, and force solid-js to its CLIENT/universal build (the package's
|
||||
* `node` export condition points at the SSR `server.js`, which lacks the
|
||||
* reactive primitives the universal renderer needs).
|
||||
*
|
||||
* `@opentui/core` stays EXTERNAL: it resolves its per-arch native `libopentui.so`
|
||||
* (and the tree-sitter worker) from its own package dir via `import.meta.url`;
|
||||
* bundling it would break those paths.
|
||||
*
|
||||
* Run with the Node that will launch the app:
|
||||
* node scripts/build.mjs # → dist/main.js (app entry)
|
||||
* node scripts/build.mjs <entry.tsx> <outdir> # build an arbitrary entry (smokes/spikes)
|
||||
* Launch:
|
||||
* node --experimental-ffi --no-warnings dist/main.js
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { transformAsync } from '@babel/core'
|
||||
import tsPreset from '@babel/preset-typescript'
|
||||
import solidPreset from 'babel-preset-solid'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
/** esbuild plugin that reproduces @opentui/solid's transform + solid-js resolution. */
|
||||
const opentuiSolid = {
|
||||
name: 'opentui-solid',
|
||||
setup(build) {
|
||||
// App JSX (.tsx/.jsx, never node_modules) → babel-preset-solid (universal).
|
||||
build.onLoad({ filter: /\.[cm]?[jt]sx$/ }, async args => {
|
||||
if (args.path.includes('/node_modules/')) return null
|
||||
const code = await readFile(args.path, 'utf8')
|
||||
const out = await transformAsync(code, {
|
||||
filename: args.path,
|
||||
configFile: false,
|
||||
babelrc: false,
|
||||
presets: [[solidPreset, { moduleName: '@opentui/solid', generate: 'universal' }], [tsPreset]]
|
||||
})
|
||||
return { contents: out?.code ?? '', loader: 'js' }
|
||||
})
|
||||
|
||||
// Force the universal/client solid-js build (node condition → server.js otherwise).
|
||||
build.onResolve({ filter: /^solid-js$/ }, () => ({ path: require.resolve('solid-js/dist/solid.js') }))
|
||||
build.onResolve({ filter: /^solid-js\/store$/ }, () => ({ path: require.resolve('solid-js/store/dist/store.js') }))
|
||||
}
|
||||
}
|
||||
|
||||
const [, , entryArg, outdirArg] = process.argv
|
||||
const entry = entryArg ? resolve(process.cwd(), entryArg) : resolve(root, 'src/entry/main.tsx')
|
||||
const outdir = outdirArg ? resolve(process.cwd(), outdirArg) : resolve(root, 'dist')
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
outdir,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node26',
|
||||
splitting: true,
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
// Native blob + tree-sitter worker resolve from @opentui/core's own dir at runtime.
|
||||
external: ['@opentui/core', '@opentui/core/*'],
|
||||
plugins: [opentuiSolid],
|
||||
define: { 'process.env.OPENTUI_BUN_ONLY_EXAMPLES': '"false"' }
|
||||
})
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase gate for the native OpenTUI engine (spec v4 §5). Runs the full headless
|
||||
# suite: format + type-check + lint + vitest (which includes the headless frame
|
||||
# gate via captureCharFrame). The agentic smoke (docs/plans/opentui-smoke.md) is
|
||||
# the live complement — run BOTH every phase.
|
||||
#
|
||||
# Runs entirely on Node 26.3 (no Bun). The OpenTUI native core loads via node:ffi
|
||||
# under --experimental-ffi; vitest passes that flag to its test forks (see
|
||||
# vitest.config.ts). Requires `node -v` == v26.3.x on PATH.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "== [1/4] format (prettier --check) =="
|
||||
npx prettier --check src
|
||||
|
||||
echo "== [2/4] type-check =="
|
||||
npm run --silent type-check
|
||||
|
||||
echo "== [3/4] lint =="
|
||||
npm run --silent lint
|
||||
|
||||
echo "== [4/4] vitest (incl. headless frame gate) =="
|
||||
npm test
|
||||
|
||||
echo "== check OK =="
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* DEV DEMO — NOT a test, NOT production. Renders the bench fixture (lorem-ipsum +
|
||||
* fat tool-turns from ./fixture.ts) in a REAL CliRenderer so you can attach over
|
||||
* tmux, scroll, and eyeball the transcript + the rolling-cap truncation notice.
|
||||
* No gateway is spawned (purely the fixture seeded into the store via the resume
|
||||
* path), so typing won't reach a backend — it's for viewing/scrolling.
|
||||
*
|
||||
* Run (Node 26 — needs the esbuild/Solid transform, then --experimental-ffi):
|
||||
* node scripts/build.mjs scripts/demo.tsx .demo
|
||||
* node --experimental-ffi --no-warnings .demo/demo.js # inside tmux (needs a TTY)
|
||||
* DEMO_TOTAL=200 fixture messages to seed (default 200)
|
||||
* HERMES_TUI_MAX_MESSAGES=80 cap → the "⤒ N earlier messages" notice fires
|
||||
* Quit: Ctrl+C.
|
||||
*/
|
||||
import { createCliRenderer } from '@opentui/core'
|
||||
import { render } from '@opentui/solid'
|
||||
|
||||
import { installMultiClickSelection } from '../src/boundary/multiClickSelect.ts'
|
||||
import { registerRemoteParsers } from '../src/boundary/parsers.ts'
|
||||
import { createSessionStore } from '../src/logic/store.ts'
|
||||
import { App } from '../src/view/App.tsx'
|
||||
import { ThemeProvider } from '../src/view/theme.tsx'
|
||||
import { materialize } from './fixture.ts'
|
||||
|
||||
// Same grammar registration as the live entry so fixture code blocks highlight
|
||||
// (fetched+cached on first use; no HERMES_TUI_PARSER_CACHE here → OpenTUI default).
|
||||
registerRemoteParsers()
|
||||
|
||||
const TOTAL = Number.parseInt(process.env.DEMO_TOTAL ?? '', 10) || 200
|
||||
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.setSessionId('demo-fixture-20260609')
|
||||
// Seed via the resume path so the cap slices + the `dropped` counter is set
|
||||
// (drives the truncation notice) exactly as a real `session.resume` would.
|
||||
store.beginBuffer()
|
||||
store.commitSnapshot(materialize(TOTAL))
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
externalOutputMode: 'passthrough',
|
||||
targetFps: 60,
|
||||
exitOnCtrlC: true,
|
||||
useKittyKeyboard: {},
|
||||
useMouse: true
|
||||
})
|
||||
// Same seam the live entry installs (boundary/renderer.ts) so the demo smokes
|
||||
// double-click word / triple-click line / drag-extend too.
|
||||
installMultiClickSelection(renderer)
|
||||
|
||||
void render(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
renderer
|
||||
)
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* DEV BENCH FIXTURE — NOT a test, NOT production code. A deterministic generator
|
||||
* for a REALISTIC heavy session, consumed by `scripts/mem-bench.tsx`. Excluded
|
||||
* from the vitest run (not a *.test.ts) and lint-clean.
|
||||
*
|
||||
* The old synthetic bench pushed tiny 3-delta turns (~5.5 mounted nodes each) —
|
||||
* an unrealistic per-message cost. Real transcripts are LUMPY: an assistant turn
|
||||
* is ONE `message` but a fat node subtree (markdown blocks + a reasoning block +
|
||||
* several tool headers, each a multi-line result). That makes message-count a
|
||||
* LOOSE proxy for memory, which is exactly what we're trying to quantify before
|
||||
* picking a `HERMES_TUI_MAX_MESSAGES` default.
|
||||
*
|
||||
* Design: a turn is modeled as a small typed `TurnAction` union (user / system /
|
||||
* gateway-event). The driver maps user→`pushUser`, system→`pushSystem`, and every
|
||||
* gateway event through the SAME `apply()` reducer real usage takes — so the
|
||||
* mounted result is identical to a live session. The same action stream also
|
||||
* materializes a settled `Message[]` (via `materialize`) for the resume-path check
|
||||
* (`commitSnapshot`). Everything is seeded by index (no `Math.random` —
|
||||
* unavailable here), so a given `total` reproduces byte-for-byte.
|
||||
*/
|
||||
import type { GatewayEvent } from '../src/boundary/schema/GatewayEvent.ts'
|
||||
import { createSessionStore, type Message } from '../src/logic/store.ts'
|
||||
|
||||
/** One scripted action in a turn: a composer push or a decoded gateway event. */
|
||||
type TurnAction =
|
||||
| { kind: 'user'; text: string }
|
||||
| { kind: 'system'; text: string }
|
||||
| { kind: 'event'; event: GatewayEvent }
|
||||
|
||||
/** A pool of lorem-ipsum words — varied content is selected by index from here. */
|
||||
const WORDS = [
|
||||
'lorem',
|
||||
'ipsum',
|
||||
'dolor',
|
||||
'sit',
|
||||
'amet',
|
||||
'consectetur',
|
||||
'adipiscing',
|
||||
'elit',
|
||||
'sed',
|
||||
'eiusmod',
|
||||
'tempor',
|
||||
'incididunt',
|
||||
'labore',
|
||||
'magna',
|
||||
'aliqua',
|
||||
'enim',
|
||||
'minim',
|
||||
'veniam',
|
||||
'quis',
|
||||
'nostrud',
|
||||
'exercitation',
|
||||
'ullamco',
|
||||
'laboris',
|
||||
'aliquip',
|
||||
'commodo',
|
||||
'consequat',
|
||||
'duis',
|
||||
'aute',
|
||||
'irure',
|
||||
'reprehenderit',
|
||||
'voluptate',
|
||||
'velit',
|
||||
'esse',
|
||||
'cillum',
|
||||
'fugiat',
|
||||
'nulla',
|
||||
'pariatur',
|
||||
'excepteur',
|
||||
'occaecat',
|
||||
'cupidatat',
|
||||
'proident',
|
||||
'sunt',
|
||||
'culpa',
|
||||
'officia',
|
||||
'deserunt',
|
||||
'mollit',
|
||||
'anim'
|
||||
] as const
|
||||
|
||||
/** Deterministic pseudo-word stream: pick from WORDS by a seeded index. */
|
||||
function word(seed: number, k: number): string {
|
||||
return WORDS[(seed * 31 + k * 7) % WORDS.length] ?? 'lorem'
|
||||
}
|
||||
|
||||
/** A lorem sentence of `n` words, capitalized + terminated. */
|
||||
function sentence(seed: number, n: number): string {
|
||||
const parts: string[] = []
|
||||
for (let k = 0; k < n; k++) parts.push(word(seed + k, k))
|
||||
const text = parts.join(' ')
|
||||
return text.charAt(0).toUpperCase() + text.slice(1) + '.'
|
||||
}
|
||||
|
||||
/** A paragraph of `s` sentences (varying length by index). */
|
||||
function paragraph(seed: number, s: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < s; i++) out.push(sentence(seed + i * 13, 6 + ((seed + i) % 9)))
|
||||
return out.join(' ')
|
||||
}
|
||||
|
||||
/** N lorem-ipsum lines (for tool result bodies), each varying in length. */
|
||||
function lines(seed: number, n: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < n; i++) out.push(sentence(seed + i * 5, 4 + ((seed + i) % 11)))
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
/** A markdown assistant body: paragraphs + a list + a fenced code block. */
|
||||
function assistantMarkdown(seed: number): string {
|
||||
const lead = paragraph(seed, 1 + (seed % 3))
|
||||
const bullets = [`- ${sentence(seed + 1, 5)}`, `- ${sentence(seed + 2, 7)}`, `- ${sentence(seed + 3, 4)}`].join('\n')
|
||||
const code = [
|
||||
'```ts',
|
||||
`const x${seed % 7} = ${seed % 100}`,
|
||||
`function f${seed % 5}() {`,
|
||||
' return x',
|
||||
'}',
|
||||
'```'
|
||||
].join('\n')
|
||||
const tail = paragraph(seed + 17, 1 + ((seed + 1) % 2))
|
||||
return `${lead}\n\n${bullets}\n\n${code}\n\n${tail}`
|
||||
}
|
||||
|
||||
/** Tool names cycled by index (mirrors a real tool mix). */
|
||||
const TOOL_NAMES = ['terminal', 'read_file', 'edit_file', 'grep', 'web_search', 'write_file'] as const
|
||||
|
||||
/** A tool.start + tool.complete pair for tool `t` in turn `seed`. */
|
||||
function toolEvents(seed: number, t: number): GatewayEvent[] {
|
||||
const id = `tool-${seed}-${t}`
|
||||
const name = TOOL_NAMES[(seed + t) % TOOL_NAMES.length] ?? 'terminal'
|
||||
const variant = (seed + t) % 3
|
||||
// short / capped-16-line / medium result bodies, mixing the render-cost cases.
|
||||
// BENCH KNOB: HERMES_BENCH_TOOL_BODY_LINES overrides the body size to N lines
|
||||
// for ALL tools — the "fat tool output" fixture (e.g. a `find /` dump) used to
|
||||
// make W3's retention win measurable. UNSET = the original tiny bodies (so the
|
||||
// default fixture stays byte-identical; existing benches are unaffected).
|
||||
const fatLines = Number.parseInt(process.env.HERMES_BENCH_TOOL_BODY_LINES ?? '', 10)
|
||||
const bodyLines = Number.isFinite(fatLines) && fatLines > 0 ? fatLines : variant === 0 ? 2 : variant === 1 ? 18 : 7
|
||||
const resultText = lines(seed + t * 3, bodyLines)
|
||||
const context = sentence(seed + t, 4)
|
||||
// ~half the tools carry a multi-line args block (the expanded-view cost).
|
||||
const withArgs = (seed + t) % 2 === 0
|
||||
const start: GatewayEvent = {
|
||||
type: 'tool.start',
|
||||
payload: withArgs ? { tool_id: id, name, context, args_text: lines(seed + t, 5) } : { tool_id: id, name, context }
|
||||
}
|
||||
const complete: GatewayEvent = {
|
||||
type: 'tool.complete',
|
||||
payload: {
|
||||
tool_id: id,
|
||||
name,
|
||||
result_text: resultText,
|
||||
duration_s: 0.1 + ((seed + t) % 40) / 10,
|
||||
args: { command: context, index: seed + t }
|
||||
}
|
||||
}
|
||||
return [start, complete]
|
||||
}
|
||||
|
||||
/** One USER message (1–4 lorem paragraphs; some very short, some RFC-sized). */
|
||||
function userText(seed: number): string {
|
||||
const shape = seed % 7
|
||||
if (shape === 0) return 'yes do that'
|
||||
if (shape === 1) return 'ok'
|
||||
if (shape === 6) {
|
||||
// an RFC-sized pasted block: many paragraphs.
|
||||
const out: string[] = []
|
||||
for (let p = 0; p < 8; p++) out.push(paragraph(seed + p * 23, 4 + (p % 3)))
|
||||
return out.join('\n\n')
|
||||
}
|
||||
const n = 1 + (seed % 4)
|
||||
const out: string[] = []
|
||||
for (let p = 0; p < n; p++) out.push(paragraph(seed + p * 11, 1 + ((seed + p) % 3)))
|
||||
return out.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scripted actions for ONE turn. Most turns are a plain user+assistant
|
||||
* exchange; a deterministic subset are tool-heavy (1–15 tool calls) or a system
|
||||
* slash-output line. Returns the actions for the whole turn in order.
|
||||
*/
|
||||
function turnActions(turn: number): TurnAction[] {
|
||||
const actions: TurnAction[] = []
|
||||
// Occasional system slash-output line (≈ every 9th turn) instead of a user line.
|
||||
if (turn % 9 === 4) {
|
||||
actions.push({ kind: 'system', text: sentence(turn, 8) })
|
||||
return actions
|
||||
}
|
||||
|
||||
actions.push({ kind: 'user', text: userText(turn) })
|
||||
actions.push({ kind: 'event', event: { type: 'message.start' } })
|
||||
|
||||
// Reasoning on ≈ every 3rd assistant turn.
|
||||
if (turn % 3 === 0) {
|
||||
actions.push({
|
||||
kind: 'event',
|
||||
event: {
|
||||
type: 'reasoning.delta',
|
||||
payload: { text: `**${sentence(turn, 3).replace(/\.$/, '')}**\n\n${paragraph(turn + 5, 2)}` }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Leading text part.
|
||||
actions.push({ kind: 'event', event: { type: 'message.delta', payload: { text: assistantMarkdown(turn) } } })
|
||||
|
||||
// Tool-heavy turns: ≈ every 4th assistant turn carries several tool calls,
|
||||
// interleaved with a follow-up text part (the fat-turn stress case).
|
||||
if (turn % 4 === 0) {
|
||||
const toolCount = 1 + (turn % 15) // 1..15 tools
|
||||
for (let t = 0; t < toolCount; t++) {
|
||||
for (const ev of toolEvents(turn, t)) actions.push({ kind: 'event', event: ev })
|
||||
}
|
||||
actions.push({ kind: 'event', event: { type: 'message.delta', payload: { text: paragraph(turn + 31, 2) } } })
|
||||
}
|
||||
|
||||
actions.push({ kind: 'event', event: { type: 'message.complete' } })
|
||||
return actions
|
||||
}
|
||||
|
||||
/** How many transcript ROWS a turn produces (user/system + at most one assistant). */
|
||||
export function rowsPerTurn(turn: number): number {
|
||||
return turn % 9 === 4 ? 1 : 2
|
||||
}
|
||||
|
||||
/** Apply ONE turn's actions to a store via the same paths real usage takes. */
|
||||
export function applyTurn(store: ReturnType<typeof createSessionStore>, turn: number): void {
|
||||
for (const action of turnActions(turn)) {
|
||||
if (action.kind === 'user') store.pushUser(action.text)
|
||||
else if (action.kind === 'system') store.pushSystem(action.text)
|
||||
else store.apply(action.event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive at least `total` MESSAGES into the live store, calling `onSample(pushes)`
|
||||
* each time the cumulative produced-row count crosses a `sampleEvery` boundary.
|
||||
* `pushes` counts MESSAGES (rows produced, pre-cap), so the matrix samples on a
|
||||
* raw message cadence regardless of the rolling cap.
|
||||
*/
|
||||
export function drive(
|
||||
store: ReturnType<typeof createSessionStore>,
|
||||
total: number,
|
||||
sampleEvery: number,
|
||||
onSample: (pushes: number) => void
|
||||
): number {
|
||||
let pushed = 0
|
||||
let nextSample = sampleEvery
|
||||
let turn = 0
|
||||
while (pushed < total) {
|
||||
applyTurn(store, turn)
|
||||
pushed += rowsPerTurn(turn)
|
||||
turn++
|
||||
while (pushed >= nextSample && nextSample <= total) {
|
||||
onSample(Math.min(pushed, total))
|
||||
nextSample += sampleEvery
|
||||
}
|
||||
}
|
||||
return turn
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the FULL settled `Message[]` for the resume path: replay the same
|
||||
* action stream into a FRESH, EFFECTIVELY-UNCAPPED store and snapshot its rows.
|
||||
* This guarantees the resume fixture is byte-identical to what the live push
|
||||
* path produces (minus the rolling cap), so `commitSnapshot` mounts the real shape.
|
||||
*/
|
||||
export function materialize(total: number): Message[] {
|
||||
// `uncappedFixture` bypasses the store's handle-safe cap CLAMP (an env value
|
||||
// can no longer raise the cap past logic/store.ts HANDLE_SAFE_MAX_ROWS — the
|
||||
// old env=MAX_SAFE_INTEGER trick would now silently truncate to 1000 rows).
|
||||
// This store is never mounted into a renderer, so no native handles are at stake.
|
||||
const store = createSessionStore({ uncappedFixture: true })
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
let pushed = 0
|
||||
let turn = 0
|
||||
while (pushed < total) {
|
||||
applyTurn(store, turn)
|
||||
pushed += rowsPerTurn(turn)
|
||||
turn++
|
||||
}
|
||||
// Deep-copy out of the solid store proxy into plain objects (the resume path
|
||||
// takes a plain Message[]).
|
||||
return store.state.messages.slice(0, total).map(cloneMessage)
|
||||
}
|
||||
|
||||
/** Plain deep copy of a store Message (drop the solid proxy + streaming flag). */
|
||||
function cloneMessage(m: Message): Message {
|
||||
const copy: Message = { role: m.role, text: m.text }
|
||||
if (m.parts) copy.parts = m.parts.map(p => ({ ...p }))
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* DEV BENCH — NOT a test, NOT production code. Throwaway memory-measurement
|
||||
* harness for tuning the rolling `HERMES_TUI_MAX_MESSAGES` cap. Mounts the
|
||||
* production `<App store={createSessionStore()}>` under the `@opentui/solid` test
|
||||
* renderer and samples `process.memoryUsage()` + the mounted-renderable count +
|
||||
* `getAllocatorStats().activeAllocations`, forcing `global.gc()` before each
|
||||
* sample. Excluded from the test run (not a *.test.ts) and lint-clean.
|
||||
*
|
||||
* It pushes a REALISTIC heavy-session fixture (scripts/fixture.ts) — varied user
|
||||
* turns + fat multi-part assistant turns (markdown + reasoning + several tool
|
||||
* headers) — because per-message size varies hugely, so message-count is only a
|
||||
* LOOSE memory proxy and we're choosing a cap default.
|
||||
*
|
||||
* node scripts/build.mjs scripts/mem-bench.tsx .bench # build once (Solid+TS → JS)
|
||||
* Uncapped: MEM_BENCH_TOTAL=8000 HERMES_TUI_MAX_MESSAGES=100000 \
|
||||
* node --experimental-ffi --expose-gc --no-warnings .bench/mem-bench.js
|
||||
* Capped: MEM_BENCH_TOTAL=8000 HERMES_TUI_MAX_MESSAGES=1500 \
|
||||
* node --experimental-ffi --expose-gc --no-warnings .bench/mem-bench.js
|
||||
*
|
||||
* Run each cap as a SEPARATE node invocation so the WASM/native heap starts fresh.
|
||||
* The matrix loop:
|
||||
* for cap in 400 1500 3000 6000 100000; do \
|
||||
* MEM_BENCH_TOTAL=8000 HERMES_TUI_MAX_MESSAGES=$cap \
|
||||
* node --experimental-ffi --expose-gc --no-warnings .bench/mem-bench.js; done
|
||||
*
|
||||
* Signal: native `getAllocatorStats().activeAllocations` (the Zig-side allocator
|
||||
* count — every live renderable/Yoga subtree contributes) and the recursive
|
||||
* renderable descendant count under `renderer.root`. RSS is reported too but is
|
||||
* noisy and grow-only (WASM linear memory never returns to the OS), so the
|
||||
* meaningful comparison is the STEADY-STATE plateau: capped should flatten after
|
||||
* ~CAP messages; uncapped should keep climbing.
|
||||
*
|
||||
* GC: forces `global.gc()` (synchronous) before each sample to measure RETAINED
|
||||
* memory, not garbage — run Node with `--expose-gc` or the GC call is a no-op.
|
||||
*
|
||||
* RESUME PATH: after the live push matrix, builds the full fixture as a settled
|
||||
* Message[] and `commitSnapshot`s it (the resume path), reporting mounted nodes +
|
||||
* RSS — verifying the slice-before-set fix bounds resume mounting to ≤ cap.
|
||||
*/
|
||||
import { resolveRenderLib } from '@opentui/core'
|
||||
import type { Renderable } from '@opentui/core'
|
||||
import { testRender } from '@opentui/solid'
|
||||
|
||||
import { createSessionStore } from '../src/logic/store.ts'
|
||||
import { App } from '../src/view/App.tsx'
|
||||
import { ThemeProvider } from '../src/view/theme.tsx'
|
||||
import { applyTurn, materialize, rowsPerTurn } from './fixture.ts'
|
||||
|
||||
const lib = resolveRenderLib()
|
||||
|
||||
const TOTAL = Number.parseInt(process.env.MEM_BENCH_TOTAL ?? '8000', 10)
|
||||
const SAMPLE_EVERY = Number.parseInt(process.env.MEM_BENCH_SAMPLE ?? '500', 10)
|
||||
const cap = process.env.HERMES_TUI_MAX_MESSAGES ?? '(default 400)'
|
||||
|
||||
const MB = (bytes: number) => (bytes / 1024 / 1024).toFixed(1)
|
||||
|
||||
/** Force a synchronous full GC to measure RETAINED memory. No-op without `node --expose-gc`. */
|
||||
const forceGc = (): void => {
|
||||
const gc = (globalThis as { gc?: () => void }).gc
|
||||
if (gc) gc()
|
||||
}
|
||||
|
||||
/** Recursively count every Renderable under root (a proxy for live Yoga nodes). */
|
||||
function descendantCount(node: Renderable): number {
|
||||
let n = 0
|
||||
for (const child of node.getChildren()) n += 1 + descendantCount(child)
|
||||
return n
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 100, height: 40, exitOnCtrlC: false }
|
||||
)
|
||||
await setup.renderOnce()
|
||||
await setup.flush()
|
||||
|
||||
process.stdout.write(
|
||||
`\n=== mem-bench (REALISTIC fixture) cap=${cap} total=${TOTAL} sampleEvery=${SAMPLE_EVERY} ===\n`
|
||||
)
|
||||
process.stdout.write(
|
||||
'pushes | msgs | rss(MB) | heapUsed(MB) | external(MB) | arrayBuf(MB) | activeAllocs | renderables\n'
|
||||
)
|
||||
process.stdout.write(
|
||||
'-------+------+---------+--------------+--------------+--------------+--------------+------------\n'
|
||||
)
|
||||
|
||||
async function sample(pushes: number): Promise<void> {
|
||||
await setup.renderOnce()
|
||||
await setup.flush()
|
||||
forceGc() // synchronous, full GC — measure retained, not garbage
|
||||
const m = process.memoryUsage()
|
||||
const alloc = lib.getAllocatorStats()
|
||||
const renderables = descendantCount(setup.renderer.root)
|
||||
const cols = [
|
||||
String(pushes).padStart(6),
|
||||
String(store.state.messages.length).padStart(4),
|
||||
MB(m.rss).padStart(7),
|
||||
MB(m.heapUsed).padStart(12),
|
||||
MB(m.external).padStart(12),
|
||||
MB(m.arrayBuffers).padStart(12),
|
||||
String(alloc.activeAllocations).padStart(12),
|
||||
String(renderables).padStart(11)
|
||||
]
|
||||
process.stdout.write(cols.join(' | ') + '\n')
|
||||
}
|
||||
|
||||
await sample(0)
|
||||
// Pump turns inline, sampling each time the cumulative produced-row count crosses
|
||||
// a SAMPLE_EVERY boundary. Sampling is async (renderOnce/flush/gc), so it lives
|
||||
// in the loop rather than a sync callback. Mounting is synchronous in Solid, so a
|
||||
// render pass at the boundary reflects the just-pushed turns.
|
||||
let pushed = 0
|
||||
let nextSample = SAMPLE_EVERY
|
||||
let turn = 0
|
||||
while (pushed < TOTAL) {
|
||||
applyTurn(store, turn)
|
||||
pushed += rowsPerTurn(turn)
|
||||
turn++
|
||||
if (pushed >= nextSample) {
|
||||
await sample(Math.min(pushed, TOTAL))
|
||||
while (nextSample <= pushed) nextSample += SAMPLE_EVERY
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down the live push tree BEFORE the resume path so its mounted nodes don't
|
||||
// pollute the process-wide RSS the resume sample reads. (The renderable COUNT is
|
||||
// already isolated per-renderer-root, but RSS is process-global.)
|
||||
store.clearTranscript()
|
||||
setup.renderer.destroy()
|
||||
forceGc()
|
||||
|
||||
// ── RESUME PATH: build the full settled fixture and commitSnapshot it (the
|
||||
// resume hydrate path). Verifies the slice-before-set fix bounds resume mounting
|
||||
// to ≤ cap — mounting 8000 settled msgs at cap=1500 should mount ~1500-worth of
|
||||
// rows, NOT 8000-worth. Done on a FRESH store + renderer so the live-push history
|
||||
// above doesn't skew the count.
|
||||
const resumeStore = createSessionStore()
|
||||
resumeStore.apply({ type: 'gateway.ready' })
|
||||
const resumeSetup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider theme={() => resumeStore.state.theme}>
|
||||
<App store={resumeStore} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 100, height: 40, exitOnCtrlC: false }
|
||||
)
|
||||
await resumeSetup.renderOnce()
|
||||
await resumeSetup.flush()
|
||||
|
||||
const fullFixture = materialize(TOTAL)
|
||||
resumeStore.beginBuffer()
|
||||
resumeStore.commitSnapshot(fullFixture)
|
||||
await resumeSetup.renderOnce()
|
||||
await resumeSetup.flush()
|
||||
forceGc()
|
||||
const rm = process.memoryUsage()
|
||||
const ralloc = lib.getAllocatorStats()
|
||||
const rrenderables = descendantCount(resumeSetup.renderer.root)
|
||||
process.stdout.write('\n--- resume path (commitSnapshot of the full fixture) ---\n')
|
||||
process.stdout.write(`fixture msgs built : ${fullFixture.length}\n`)
|
||||
process.stdout.write(`mounted msgs (cap) : ${resumeStore.state.messages.length}\n`)
|
||||
process.stdout.write(`mounted renderables: ${rrenderables}\n`)
|
||||
process.stdout.write(`activeAllocations : ${ralloc.activeAllocations}\n`)
|
||||
process.stdout.write(`rss(MB) : ${MB(rm.rss)}\n`)
|
||||
|
||||
resumeSetup.renderer.destroy()
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Clipboard (item 1) — copy via OSC 52 (works over SSH/tmux) + a native platform
|
||||
* command, and read a clipboard IMAGE for paste-to-attach. Ported/trimmed from
|
||||
* opencode `clipboard.ts`. A boundary concern (spawns processes / writes stdout);
|
||||
* everything is best-effort and never throws into the view.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { platform } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** Whether `cmd` resolves on PATH (cached). We DON'T spawn missing tools: a failed
|
||||
* spawn + writing to its dead stdin pipe raises EPIPE/SIGPIPE, and OpenTUI used to
|
||||
* treat SIGPIPE as a shutdown signal — i.e. a clipboard miss would quit the TUI.
|
||||
* Skipped on Windows (the built-in `clip` is always present; PATHEXT complicates
|
||||
* a filename probe). */
|
||||
const commandCache = new Map<string, boolean>()
|
||||
function commandExists(cmd: string): boolean {
|
||||
if (platform() === 'win32') return true
|
||||
const cached = commandCache.get(cmd)
|
||||
if (cached !== undefined) return cached
|
||||
const dirs = (process.env.PATH ?? '').split(':').filter(Boolean)
|
||||
const found = dirs.some(dir => existsSync(join(dir, cmd)))
|
||||
commandCache.set(cmd, found)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Run a command, optionally piping `input` to stdin; resolve its stdout bytes.
|
||||
* Best-effort and crash-proof: every stream error (incl. EPIPE → SIGPIPE on a
|
||||
* clipboard tool that exits early) is swallowed so a failed copy never throws out
|
||||
* of the boundary or signals the process. */
|
||||
function run(cmd: string, args: string[] = [], input?: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(cmd, args, { stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'] })
|
||||
} catch (cause) {
|
||||
reject(cause instanceof Error ? cause : new Error(String(cause)))
|
||||
return
|
||||
}
|
||||
const out: Buffer[] = []
|
||||
child.on('error', reject)
|
||||
child.stdout?.on('error', () => {}) // a closed stdout pipe must not throw
|
||||
child.stdout?.on('data', (c: Buffer) => out.push(c))
|
||||
child.on('close', code => (code === 0 ? resolve(Buffer.concat(out)) : reject(new Error(`${cmd} exit ${code}`))))
|
||||
if (input !== undefined && child.stdin) {
|
||||
// Writing to a tool that died/closed early raises EPIPE (→ SIGPIPE). Swallow it.
|
||||
child.stdin.on('error', () => {})
|
||||
try {
|
||||
child.stdin.end(input)
|
||||
} catch {
|
||||
// pipe already gone — nothing to flush
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** OSC 52 copy — the terminal puts `text` on the system clipboard (SSH/tmux-safe). */
|
||||
function writeOsc52(text: string): void {
|
||||
if (!process.stdout.isTTY) return
|
||||
const seq = `\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`
|
||||
// tmux/screen need the sequence wrapped in their passthrough escape.
|
||||
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${seq}\x1b\\` : seq)
|
||||
}
|
||||
|
||||
/** Native copy commands to try, in order, for the current platform. */
|
||||
function copyCandidates(): Array<[string, string[]]> {
|
||||
const os = platform()
|
||||
if (os === 'darwin') return [['pbcopy', []]]
|
||||
if (os === 'win32') return [['clip', []]]
|
||||
// linux: prefer Wayland, then X11 tools
|
||||
const list: Array<[string, string[]]> = []
|
||||
if (process.env.WAYLAND_DISPLAY) list.push(['wl-copy', []])
|
||||
list.push(['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']])
|
||||
return list
|
||||
}
|
||||
|
||||
/** Copy `text` to the clipboard: OSC 52 (always) + the first native command that works. */
|
||||
export async function writeClipboard(text: string): Promise<void> {
|
||||
writeOsc52(text) // primary path — SSH/tmux-safe, no subprocess
|
||||
for (const [cmd, args] of copyCandidates()) {
|
||||
if (!commandExists(cmd)) continue // never spawn a missing tool (avoids EPIPE/SIGPIPE)
|
||||
try {
|
||||
await run(cmd, args, text)
|
||||
return
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a clipboard IMAGE as base64 PNG (for paste-to-attach); undefined if none. */
|
||||
export async function readClipboardImage(): Promise<{ data: string; mime: string } | undefined> {
|
||||
const os = platform()
|
||||
const tries: Array<[string, string[]]> = []
|
||||
if (os === 'linux') {
|
||||
if (process.env.WAYLAND_DISPLAY) tries.push(['wl-paste', ['-t', 'image/png']])
|
||||
tries.push(['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']])
|
||||
} else if (os === 'darwin') {
|
||||
tries.push(['pngpaste', ['-']]) // brew install pngpaste
|
||||
} else if (os === 'win32') {
|
||||
tries.push([
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NonInteractive',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
'Add-Type -AssemblyName System.Windows.Forms; $img=[System.Windows.Forms.Clipboard]::GetImage(); if($img){$ms=New-Object System.IO.MemoryStream; $img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); [Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))}'
|
||||
]
|
||||
])
|
||||
}
|
||||
for (const [cmd, args] of tries) {
|
||||
if (!commandExists(cmd)) continue // skip missing tools (no pointless failing spawns)
|
||||
try {
|
||||
const buf = await run(cmd, args)
|
||||
if (buf.length) {
|
||||
// powershell already returns base64 text; the others return raw PNG bytes.
|
||||
const data = os === 'win32' ? buf.toString('utf8').trim() : buf.toString('base64')
|
||||
if (data) return { data, mime: 'image/png' }
|
||||
}
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Typed errors at the gateway boundary.
|
||||
*
|
||||
* Per spec v4 §3.4: internal errors use `Data.TaggedError`; wire/serializable
|
||||
* errors use Schema-based tagged errors (added in Phase 1 alongside the
|
||||
* GatewayEvent schema). Phase 0 ships the internal set the renderer/transport
|
||||
* boundary needs.
|
||||
*
|
||||
* Boundary code yields these directly (`return yield* new FooError(...)`) — no
|
||||
* throw / try-catch / Promise.catch / orDie.
|
||||
*/
|
||||
import { Data } from 'effect'
|
||||
|
||||
/** The renderer (createCliRenderer) failed to acquire. */
|
||||
export class RendererError extends Data.TaggedError('RendererError')<{
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
/** Could not resolve a usable Python interpreter for the gateway. */
|
||||
export class PythonResolutionError extends Data.TaggedError('PythonResolutionError')<{
|
||||
readonly tried: ReadonlyArray<string>
|
||||
}> {}
|
||||
|
||||
/** A JSON-RPC request to the gateway failed (timeout, transport down, rpc error). */
|
||||
export class GatewayError extends Data.TaggedError('GatewayError')<{
|
||||
readonly method: string
|
||||
readonly reason: 'timeout' | 'transport-down' | 'rpc-error'
|
||||
readonly message: string
|
||||
}> {}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Node-FFI coordinate safety shim for @opentui/core 0.4.0.
|
||||
*
|
||||
* Root cause (live crash, ERR_INVALID_ARG_VALUE looping every frame): several
|
||||
* OptimizedBuffer methods marshal x/y/width/height as **u32** in the FFI table
|
||||
* (zig.ts: `bufferFillRect: ["u32","u32","u32","u32","u32","ptr"]`, same for
|
||||
* `bufferDrawText` / `bufferSetCell*` / `bufferDrawChar`), while renderables
|
||||
* pass RAW SCREEN COORDINATES — which go NEGATIVE inside a <scrollbox> when an
|
||||
* element is partially scrolled above the viewport. Concretely:
|
||||
* `LineNumberRenderable.renderSelf` does `buffer.fillRect(this.x + gutterWidth,
|
||||
* this.y + i, …)` for diff added/removed line backgrounds, so expanding a tall
|
||||
* `<diff showLineNumbers>` pinned to the scrollbox bottom rendered with
|
||||
* `this.y < 0` and threw out of `CliRenderer.loop` on EVERY frame (frozen UI,
|
||||
* console error spam) until a resize forced a fresh layout.
|
||||
*
|
||||
* Upstream-on-Bun this never throws: Bun's FFI silently WRAPS negatives to
|
||||
* huge u32s and the native side bounds-checks them into a no-op. Node's
|
||||
* experimental FFI (node:ffi) instead REJECTS the argument. Other draw entry
|
||||
* points (`bufferDrawBox`, `bufferDrawTextBufferView`) already use i32 — which
|
||||
* is why ordinary text/boxes scroll fine and only the diff gutter path crashed.
|
||||
*
|
||||
* Fix at the seam we own: clamp/skip BEFORE the FFI call.
|
||||
* - fillRect: clip the rect to the non-negative quadrant (the native side
|
||||
* already clips right/bottom against the buffer + scissor) and skip empties.
|
||||
* - drawText/setCell/setCellWithAlphaBlending/drawChar: skip when the origin
|
||||
* is negative (Bun-parity: those cells/rows are off-screen anyway).
|
||||
*
|
||||
* TODO(upstream): file/track an OpenTUI issue to widen these FFI params to i32
|
||||
* (or clamp in core) — then this shim can be deleted.
|
||||
*/
|
||||
import { OptimizedBuffer, TextBufferView } from '@opentui/core'
|
||||
|
||||
let installed = false
|
||||
|
||||
/** Patch OptimizedBuffer's u32-coordinate methods to tolerate negative coords. Idempotent. */
|
||||
export function installFfiCoordSafety(): void {
|
||||
if (installed) return
|
||||
installed = true
|
||||
|
||||
const proto = OptimizedBuffer.prototype
|
||||
|
||||
// Prototype monkey-patching: extracting the original methods unbound is the
|
||||
// point — they're re-invoked with `.call(this, …)` on the correct instance.
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
|
||||
const origFillRect = proto.fillRect
|
||||
proto.fillRect = function (this: OptimizedBuffer, x, y, width, height, bg) {
|
||||
let x2 = Math.trunc(x)
|
||||
let y2 = Math.trunc(y)
|
||||
let w = Math.trunc(width)
|
||||
let h = Math.trunc(height)
|
||||
if (x2 < 0) {
|
||||
w += x2
|
||||
x2 = 0
|
||||
}
|
||||
if (y2 < 0) {
|
||||
h += y2
|
||||
y2 = 0
|
||||
}
|
||||
if (w <= 0 || h <= 0) return
|
||||
origFillRect.call(this, x2, y2, w, h, bg)
|
||||
}
|
||||
|
||||
const origDrawText = proto.drawText
|
||||
proto.drawText = function (this: OptimizedBuffer, text, x, y, ...rest) {
|
||||
if (x < 0 || y < 0) return
|
||||
origDrawText.call(this, text, x, y, ...rest)
|
||||
}
|
||||
|
||||
const origSetCell = proto.setCell
|
||||
proto.setCell = function (this: OptimizedBuffer, x, y, ...rest) {
|
||||
if (x < 0 || y < 0) return
|
||||
origSetCell.call(this, x, y, ...rest)
|
||||
}
|
||||
|
||||
const origSetCellAlpha = proto.setCellWithAlphaBlending
|
||||
proto.setCellWithAlphaBlending = function (this: OptimizedBuffer, x, y, ...rest) {
|
||||
if (x < 0 || y < 0) return
|
||||
origSetCellAlpha.call(this, x, y, ...rest)
|
||||
}
|
||||
|
||||
const origDrawChar = proto.drawChar
|
||||
proto.drawChar = function (this: OptimizedBuffer, char, x, y, ...rest) {
|
||||
if (x < 0 || y < 0) return
|
||||
origDrawChar.call(this, char, x, y, ...rest)
|
||||
}
|
||||
|
||||
// Same u32 marshaling on a different entry point: `textBufferViewSetViewport`
|
||||
// takes x/y/width/height as u32, but `TextRenderable.onResize` feeds it the
|
||||
// RAW transient layout size — observed NON-u32 (negative/NaN) mid-relayout
|
||||
// while a shrinking list (the fuzzy picker filtering rows away) reflows. Bun
|
||||
// wraps/coerces, node:ffi throws (`Argument 3 must be a uint32`). Coerce into
|
||||
// the valid quadrant; a zero-sized viewport is the native side's own no-op.
|
||||
const u32 = (v: number) => (Number.isFinite(v) ? Math.max(0, Math.trunc(v)) : 0)
|
||||
const viewProto = TextBufferView.prototype
|
||||
const origSetViewport = viewProto.setViewport
|
||||
viewProto.setViewport = function (this: TextBufferView, x, y, width, height) {
|
||||
origSetViewport.call(this, u32(x), u32(y), u32(width), u32(height))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* GatewayService — the Effect-side transport boundary.
|
||||
*
|
||||
* Phase 0: the SHAPE only. The live layer (spawning the Python `tui_gateway`,
|
||||
* JSON-RPC framing, Schema-decoding the wire union) lands in Phase 1
|
||||
* (`boundary/gateway/liveGateway.ts`). For now the only implementation is
|
||||
* `FakeGateway.layer` (entry/fakeGateway.ts), which the render/test harness uses.
|
||||
*
|
||||
* This is one of exactly two Effect<->Solid contact points: the Solid store
|
||||
* subscribes via `subscribe(handler)` and the boundary pushes DECODED events in.
|
||||
* Per spec v4 §1, the store/reducer themselves are plain Solid, never Effect.
|
||||
*/
|
||||
import { Context, type Effect } from 'effect'
|
||||
|
||||
import type { GatewayError } from '../errors.ts'
|
||||
import type { GatewayEvent } from '../schema/GatewayEvent.ts'
|
||||
|
||||
export interface GatewayServiceShape {
|
||||
/** Push decoded gateway events into the Solid store. Returns an unsubscribe fn. */
|
||||
readonly subscribe: (handler: (event: GatewayEvent) => void) => Effect.Effect<() => void>
|
||||
/** Typed JSON-RPC request to the Python gateway. Fails with a typed GatewayError, never throws. */
|
||||
readonly request: <A>(method: string, params: unknown) => Effect.Effect<A, GatewayError>
|
||||
/** The active session id (for `approval.respond {session_id}`); undefined before a session exists. */
|
||||
readonly sessionId: () => string | undefined
|
||||
}
|
||||
|
||||
export class GatewayService extends Context.Service<GatewayService, GatewayServiceShape>()(
|
||||
'@hermes-tui/GatewayService'
|
||||
) {}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Low-level JSON-RPC-over-stdio client for the Python `tui_gateway` (spec v4 §4).
|
||||
* Re-authored minimal (NOT the Ink client's 740-LOC attach-mode/buffering) but
|
||||
* the WIRE CONTRACT is identical (verified against ui-tui/src/gatewayClient.ts +
|
||||
* tui_gateway/server.py + entry.py + transport.py):
|
||||
*
|
||||
* - spawn: `python -m tui_gateway.entry`, cwd=srcRoot, env={...process.env,
|
||||
* PYTHONPATH=srcRoot:…, HERMES_PYTHON_SRC_ROOT=srcRoot}, stdio piped.
|
||||
* - framing: newline-delimited compact JSON, BOTH directions, on ONE stdout.
|
||||
* - request: {id:"r<n>", jsonrpc:"2.0", method, params} + "\n".
|
||||
* - response: {jsonrpc, id, result} | {jsonrpc, id, error:{code,message}} — match by id.
|
||||
* - event: {jsonrpc, method:"event", params:{type, session_id?, payload?}} (NO id).
|
||||
* - handshake: child emits {event, params:{type:"gateway.ready", payload:{skin}}}
|
||||
* UNSOLICITED first; no subscribe RPC. Then client drives session.create /
|
||||
* session.resume / prompt.submit / *.respond.
|
||||
* - GOTCHA: session.resume/prompt.submit/slash.exec are LONG handlers — their
|
||||
* {id,result} arrives async, interleaved with events. Keep the pending map
|
||||
* authoritative; never assume in-order response delivery.
|
||||
*
|
||||
* Raw events are surfaced as `unknown` (the params object). The liveGateway
|
||||
* layer Schema-decodes them once at the boundary (spec v4 §3.3); this client
|
||||
* stays decode-agnostic so the transport and the schema evolve independently.
|
||||
*/
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
|
||||
import type { Log } from '../log.ts'
|
||||
import { resolvePython, resolveSrcRoot } from './python.ts'
|
||||
|
||||
interface Pending {
|
||||
resolve: (result: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
method: string
|
||||
}
|
||||
|
||||
export interface RawClientOptions {
|
||||
readonly log: Log
|
||||
/** Called with each server-pushed event's `params` object (still unknown — decoded upstream). */
|
||||
readonly onEvent: (params: unknown) => void
|
||||
/** Called when the child exits / errors (so the layer can reject pending + reconnect). */
|
||||
readonly onExit?: (reason: string) => void
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = (() => {
|
||||
const raw = Number.parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '', 10)
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.max(5000, raw) : 120_000
|
||||
})()
|
||||
|
||||
const STARTUP_TIMEOUT_MS = (() => {
|
||||
const raw = Number.parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '', 10)
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.max(2000, raw) : 20_000
|
||||
})()
|
||||
|
||||
export class RawGatewayClient {
|
||||
private proc: ChildProcessWithoutNullStreams | null = null
|
||||
private pending = new Map<string, Pending>()
|
||||
private reqId = 0
|
||||
private stdinBuffer = ''
|
||||
private startupTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private readonly log: Log
|
||||
private readonly onEvent: (params: unknown) => void
|
||||
private readonly onExit?: (reason: string) => void
|
||||
|
||||
constructor(options: RawClientOptions) {
|
||||
this.log = options.log
|
||||
this.onEvent = options.onEvent
|
||||
if (options.onExit) this.onExit = options.onExit
|
||||
}
|
||||
|
||||
/** Spawn the gateway child and begin reading frames. Idempotent. */
|
||||
start(): void {
|
||||
if (this.proc) return
|
||||
const srcRoot = resolveSrcRoot()
|
||||
const python = resolvePython(srcRoot)
|
||||
const cwd = process.env.HERMES_CWD?.trim() || srcRoot
|
||||
const env: Record<string, string> = { ...(process.env as Record<string, string>) }
|
||||
env.PYTHONPATH = env.PYTHONPATH ? `${srcRoot}:${env.PYTHONPATH}` : srcRoot
|
||||
env.HERMES_PYTHON_SRC_ROOT = srcRoot
|
||||
|
||||
this.log.info('gateway', 'spawning tui_gateway', { python, cwd, srcRoot })
|
||||
|
||||
const proc = spawn(python, ['-m', 'tui_gateway.entry'], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
// Identity guard: a stale child's late exit/error must not act after a restart
|
||||
// has already installed a new `this.proc` (else it'd null the live child).
|
||||
// Nulling `this.proc` here makes a subsequent finish() a no-op (idempotent),
|
||||
// covering the ENOENT case where 'error' fires and 'exit' does not.
|
||||
const finish = (reason: string) => {
|
||||
if (this.proc !== proc) return
|
||||
this.log.warn('gateway', reason)
|
||||
this.rejectAll(reason)
|
||||
this.proc = null
|
||||
this.onExit?.(reason)
|
||||
}
|
||||
proc.on('exit', (code, signal) => finish(`gateway exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`))
|
||||
proc.on('error', err => finish(`gateway spawn error: ${err instanceof Error ? err.message : String(err)}`))
|
||||
this.proc = proc
|
||||
this.readStdout(proc)
|
||||
this.readStderr(proc)
|
||||
|
||||
// Startup-readiness watchdog: a child that hangs on import (wrong python /
|
||||
// missing dep) never emits the unsolicited `gateway.ready` handshake, leaving
|
||||
// a silent blank UI. Emit `gateway.start_timeout` so the store can surface a
|
||||
// failure line + the captured stderr tail. Cleared on ready (dispatch) / stop.
|
||||
// A recovery-respawn re-enters start(), so this re-arms per respawn — desired.
|
||||
this.startupTimer = setTimeout(() => {
|
||||
this.startupTimer = undefined
|
||||
this.onEvent({
|
||||
type: 'gateway.start_timeout',
|
||||
payload: { message: `no gateway.ready within ${STARTUP_TIMEOUT_MS}ms` }
|
||||
})
|
||||
}, STARTUP_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private readStdout(proc: ChildProcessWithoutNullStreams): void {
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
this.stdinBuffer += chunk
|
||||
let nl: number
|
||||
while ((nl = this.stdinBuffer.indexOf('\n')) >= 0) {
|
||||
const line = this.stdinBuffer.slice(0, nl)
|
||||
this.stdinBuffer = this.stdinBuffer.slice(nl + 1)
|
||||
if (line.trim()) this.dispatch(line)
|
||||
}
|
||||
})
|
||||
proc.stdout.on('error', cause => this.log.error('gateway', 'stdout read loop failed', { cause: String(cause) }))
|
||||
}
|
||||
|
||||
private readStderr(proc: ChildProcessWithoutNullStreams): void {
|
||||
let buf = ''
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (chunk: string) => {
|
||||
buf += chunk
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl)
|
||||
buf = buf.slice(nl + 1)
|
||||
if (line.trim()) {
|
||||
this.log.debug('gateway.stderr', line)
|
||||
// Surface as a synthetic gateway.stderr event (matches Ink).
|
||||
this.onEvent({ type: 'gateway.stderr', payload: { line } })
|
||||
}
|
||||
}
|
||||
})
|
||||
// stderr pipe closing on exit is expected; ignore errors.
|
||||
proc.stderr.on('error', () => {})
|
||||
}
|
||||
|
||||
private dispatch(line: string): void {
|
||||
let msg: unknown
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
this.log.warn('gateway', 'unparseable frame', { preview: line.slice(0, 120) })
|
||||
this.onEvent({ type: 'gateway.protocol_error', payload: { preview: line.slice(0, 120) } })
|
||||
return
|
||||
}
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
const frame = msg as { id?: unknown; method?: unknown; params?: unknown; result?: unknown; error?: unknown }
|
||||
|
||||
// Response: has an id matching a pending request.
|
||||
const pending = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined
|
||||
if (typeof frame.id === 'string' && pending) {
|
||||
const p = pending
|
||||
this.pending.delete(frame.id)
|
||||
if (frame.error) {
|
||||
const err = frame.error as { code?: number; message?: string }
|
||||
p.reject(new Error(err.message ?? `rpc error (${err.code ?? '?'})`))
|
||||
} else {
|
||||
p.resolve(frame.result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Event push: method === "event", no id. Surface params (decoded upstream).
|
||||
if (frame.method === 'event' && frame.params && typeof frame.params === 'object') {
|
||||
// Handshake arrived: cancel the startup-readiness watchdog. Narrow without
|
||||
// `as` via `'type' in obj` + property access (the params record is loose).
|
||||
if ('type' in frame.params && frame.params.type === 'gateway.ready') {
|
||||
if (this.startupTimer) clearTimeout(this.startupTimer)
|
||||
this.startupTimer = undefined
|
||||
}
|
||||
this.onEvent(frame.params)
|
||||
return
|
||||
}
|
||||
|
||||
this.log.warn('gateway', 'unroutable frame', { preview: line.slice(0, 120) })
|
||||
}
|
||||
|
||||
/** Send a JSON-RPC request; resolves with `result` (long handlers reply async). */
|
||||
request<A = unknown>(method: string, params: unknown): Promise<A> {
|
||||
// Do NOT auto-start here: during the recovery backoff window `this.proc` is
|
||||
// null, and a respawn here would BYPASS the backoff (the first spawn always
|
||||
// comes from subscribe() → client.start()). A null proc rejects below.
|
||||
const proc = this.proc
|
||||
const stdin = proc?.stdin
|
||||
if (!stdin) return Promise.reject(new Error('gateway not running'))
|
||||
|
||||
const id = `r${++this.reqId}`
|
||||
const frame = JSON.stringify({ id, jsonrpc: '2.0', method, params: params ?? {} }) + '\n'
|
||||
|
||||
return new Promise<A>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending.delete(id)) reject(new Error(`timeout: ${method}`))
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
this.pending.set(id, {
|
||||
method,
|
||||
resolve: result => {
|
||||
clearTimeout(timer)
|
||||
resolve(result as A)
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
// Newline-delimited JSON to the child's stdin. Fire-and-forget: the write
|
||||
// returns a backpressure boolean we intentionally ignore (frames are tiny
|
||||
// and ordered; Node flushes the pipe itself).
|
||||
stdin.write(frame)
|
||||
} catch (cause) {
|
||||
this.pending.delete(id)
|
||||
clearTimeout(timer)
|
||||
reject(cause instanceof Error ? cause : new Error(String(cause)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private rejectAll(reason: string): void {
|
||||
for (const p of this.pending.values()) p.reject(new Error(reason))
|
||||
this.pending.clear()
|
||||
}
|
||||
|
||||
/** Close stdin (EOF → child exits) and stop. */
|
||||
stop(): void {
|
||||
if (this.startupTimer) clearTimeout(this.startupTimer)
|
||||
this.startupTimer = undefined
|
||||
this.rejectAll('gateway stopping')
|
||||
const stdin = this.proc?.stdin
|
||||
if (stdin) {
|
||||
try {
|
||||
// Close stdin → child sees EOF and exits.
|
||||
stdin.end()
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
this.proc = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* liveGateway — the GatewayService layer backed by the real Python `tui_gateway`
|
||||
* (spec v4 §2/§3.2). Adapts RawGatewayClient to GatewayServiceShape:
|
||||
* - decodes each raw event ONCE with the GatewayEvent Schema
|
||||
* (decodeUnknownOption → unrecognized/malformed events skipped, never crash),
|
||||
* - coalesces decoded events on a 16ms debounce flushed inside Solid `batch()`
|
||||
* so a burst of deltas is ONE repaint (opencode sdk.tsx:54-80),
|
||||
* - tracks the session id (set from session.create/resume result) for
|
||||
* approval.respond {session_id},
|
||||
* - maps request failures to a typed GatewayError (never throws).
|
||||
*
|
||||
* The 16ms batch + `batch()` call is the boundary handing decoded events to
|
||||
* Solid — one of the two approved Effect<->Solid contact points (spec v4 §1).
|
||||
*/
|
||||
import { Effect, Layer, Option, Schema } from 'effect'
|
||||
import { batch } from 'solid-js'
|
||||
|
||||
import { backoffMs, planGatewayRecovery } from '../../logic/gatewayRecovery.ts'
|
||||
import { GatewayError } from '../errors.ts'
|
||||
import { getLog } from '../log.ts'
|
||||
import { GatewayEventSchema, type GatewayEvent } from '../schema/GatewayEvent.ts'
|
||||
import { GatewayService, type GatewayServiceShape } from './GatewayService.ts'
|
||||
import { RawGatewayClient } from './client.ts'
|
||||
|
||||
const COALESCE_MS = 16
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownOption(GatewayEventSchema)
|
||||
|
||||
function makeLiveGateway(): { service: GatewayServiceShape; stop: () => void } {
|
||||
const log = getLog()
|
||||
const handlers = new Set<(event: GatewayEvent) => void>()
|
||||
let sessionId: string | undefined
|
||||
|
||||
// Auto-heal recovery state (driver below). `recoverSid` is the resume target
|
||||
// carried across a respawn that died before gateway.ready; `recoveryAttempts`
|
||||
// is the sliding crash-loop budget window; `restartTimer` is the pending
|
||||
// backoff respawn (cleared on teardown so it can't fire post-stop).
|
||||
let recoverSid: string | undefined
|
||||
let recoveryAttempts: number[] = []
|
||||
let restartTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
// 16ms event coalescing → one batched repaint (opencode sdk.tsx model).
|
||||
let queue: GatewayEvent[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const flush = () => {
|
||||
timer = undefined
|
||||
if (queue.length === 0) return
|
||||
const events = queue
|
||||
queue = []
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
for (const handler of handlers) handler(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const enqueue = (event: GatewayEvent) => {
|
||||
queue.push(event)
|
||||
if (timer) return
|
||||
// If we flushed recently (<16ms ago) batch with near-future events; else flush now.
|
||||
if (Date.now() - last < COALESCE_MS) {
|
||||
timer = setTimeout(flush, COALESCE_MS)
|
||||
} else {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
const onRawEvent = (params: unknown) => {
|
||||
const decoded = decodeEvent(params)
|
||||
if (Option.isNone(decoded)) {
|
||||
const t = (params as { type?: unknown } | null)?.type
|
||||
log.debug('gateway', 'skipped undecodable event', { type: typeof t === 'string' ? t : '(none)' })
|
||||
return
|
||||
}
|
||||
enqueue(decoded.value)
|
||||
}
|
||||
|
||||
// Recovery driver: on a child exit, clear the frozen spinner (via the store's
|
||||
// gateway.exited case), then — under the crash-loop budget — respawn the child
|
||||
// on exponential backoff. The post-respawn gateway.ready triggers the re-resume
|
||||
// (driven from entry's subscribe callback). Hoisted so it can be passed to
|
||||
// `new RawGatewayClient` below while itself referencing the `client` const —
|
||||
// `client` is assigned by the time onExit ever fires at runtime.
|
||||
function onExit(reason: string): void {
|
||||
log.warn('gateway', 'transport exited', { reason })
|
||||
// Clears the frozen spinner + shows status (store handles gateway.exited).
|
||||
enqueue({ type: 'gateway.exited', payload: { reason } })
|
||||
const plan = planGatewayRecovery(sessionId ?? null, recoverSid ?? null, recoveryAttempts, Date.now())
|
||||
recoveryAttempts = plan.attempts
|
||||
if (!plan.recover || plan.sid === null) {
|
||||
enqueue({ type: 'error', payload: { message: 'gateway exited repeatedly — type /resume to retry' } })
|
||||
return
|
||||
}
|
||||
recoverSid = plan.sid
|
||||
const attempt = recoveryAttempts.length
|
||||
const delay = backoffMs(attempt)
|
||||
enqueue({ type: 'gateway.recovering', payload: { attempt, delay_ms: delay } })
|
||||
if (restartTimer) clearTimeout(restartTimer)
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = undefined
|
||||
client.start()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const client = new RawGatewayClient({
|
||||
log,
|
||||
onEvent: onRawEvent,
|
||||
onExit
|
||||
})
|
||||
|
||||
const service: GatewayServiceShape = {
|
||||
subscribe: handler =>
|
||||
Effect.sync(() => {
|
||||
handlers.add(handler)
|
||||
// Lazily spawn on first subscription so the child + its gateway.ready land.
|
||||
client.start()
|
||||
return () => {
|
||||
handlers.delete(handler)
|
||||
}
|
||||
}),
|
||||
|
||||
request: <A>(method: string, params: unknown) =>
|
||||
Effect.tryPromise({
|
||||
try: () => client.request<A>(method, params),
|
||||
catch: cause => {
|
||||
const message = cause instanceof Error ? cause.message : String(cause)
|
||||
const reason = message.startsWith('timeout:')
|
||||
? ('timeout' as const)
|
||||
: message.includes('not running') || message.includes('stopping')
|
||||
? ('transport-down' as const)
|
||||
: ('rpc-error' as const)
|
||||
return new GatewayError({ method, reason, message })
|
||||
}
|
||||
}).pipe(
|
||||
// Capture session id from create/resume results so approval.respond works.
|
||||
Effect.tap(result =>
|
||||
Effect.sync(() => {
|
||||
if ((method === 'session.create' || method === 'session.resume') && result && typeof result === 'object') {
|
||||
const sid = (result as { session_id?: unknown }).session_id
|
||||
if (typeof sid === 'string') sessionId = sid
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
|
||||
sessionId: () => sessionId
|
||||
}
|
||||
|
||||
// Clear a pending coalesce timer on teardown so a queued flush() can't fire
|
||||
// batch()/handlers into a torn-down store after the layer scope releases.
|
||||
const stop = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
// Also kill any pending backoff respawn so it can't fire after teardown.
|
||||
if (restartTimer) clearTimeout(restartTimer)
|
||||
restartTimer = undefined
|
||||
client.stop()
|
||||
}
|
||||
return { service, stop }
|
||||
}
|
||||
|
||||
/**
|
||||
* The live GatewayService layer (spawns + talks to the real Python tui_gateway).
|
||||
* Scoped so the child process is stopped (stdin EOF → exit) on scope teardown —
|
||||
* no orphaned gateway children when the renderer is destroyed.
|
||||
*/
|
||||
export const liveGatewayLayer: Layer.Layer<GatewayService> = Layer.effect(
|
||||
GatewayService,
|
||||
Effect.acquireRelease(Effect.sync(makeLiveGateway), ({ stop }) => Effect.sync(stop)).pipe(
|
||||
Effect.map(({ service }) => service)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Python resolution for spawning the `tui_gateway` — mirrors Ink's
|
||||
* `resolvePython` (ui-tui/src/gatewayClient.ts:45-64) EXACTLY so behavior is
|
||||
* identical across engines (spec v4 §4). NEVER "probe any python".
|
||||
*
|
||||
* Order: HERMES_PYTHON / PYTHON env → $VIRTUAL_ENV (bin/python or
|
||||
* Scripts/python.exe) → <root>/.venv → <root>/venv → bare `python3` (`python`
|
||||
* on win32) on PATH. The source root is HERMES_PYTHON_SRC_ROOT (the launcher
|
||||
* sets it) so the child resolves modules against the right checkout.
|
||||
*/
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
export function resolvePython(root: string): string {
|
||||
const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
|
||||
if (configured) return configured
|
||||
|
||||
const venv = process.env.VIRTUAL_ENV?.trim()
|
||||
|
||||
const hit = [
|
||||
venv && resolve(venv, 'bin/python'),
|
||||
venv && resolve(venv, 'Scripts/python.exe'),
|
||||
resolve(root, '.venv/bin/python'),
|
||||
resolve(root, '.venv/bin/python3'),
|
||||
resolve(root, 'venv/bin/python'),
|
||||
resolve(root, 'venv/bin/python3')
|
||||
].find(p => p && existsSync(p))
|
||||
|
||||
return hit || (process.platform === 'win32' ? 'python' : 'python3')
|
||||
}
|
||||
|
||||
/** The Hermes checkout root used as PYTHONPATH / HERMES_PYTHON_SRC_ROOT for the child. */
|
||||
export function resolveSrcRoot(): string {
|
||||
const configured = process.env.HERMES_PYTHON_SRC_ROOT?.trim()
|
||||
if (configured) return configured
|
||||
// Fallback (no launcher env): walk up from this module to the Hermes checkout
|
||||
// root — the dir holding the `hermes_cli` package / `pyproject.toml`. Bundle-
|
||||
// agnostic, so it works whether running the source tree (.../src/boundary/gateway)
|
||||
// or the built `dist/main.js`. (Under the real launcher this never runs — the
|
||||
// launcher always sets HERMES_PYTHON_SRC_ROOT.)
|
||||
let dir = import.meta.dirname
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (existsSync(resolve(dir, 'hermes_cli')) || existsSync(resolve(dir, 'pyproject.toml'))) return dir
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
return resolve(import.meta.dirname, '../../../../')
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Log — TUI diagnostics sink (glitch: "v. important … hook into logs to figure
|
||||
* out TUI state"). Design mirrors opencode's `util/log.ts` (levels + priority
|
||||
* filter, scoped/child loggers, a `.time()` span helper) but adds a dual sink:
|
||||
*
|
||||
* 1. an in-memory RING BUFFER (queryable at runtime — a `/logs` overlay or a
|
||||
* test asserting TUI state transitions can read it live), AND
|
||||
* 2. an append-only NDJSON FILE (default `~/.hermes/logs/opentui-v2.log`,
|
||||
* override via HERMES_TUI_LOG_FILE) so a live session is `tail -f`-able.
|
||||
*
|
||||
* The ring buffer is the key advantage over opencode's file-only logger: it lets
|
||||
* us inspect engine state from inside the running TUI without leaving it.
|
||||
*
|
||||
* CRITICAL: OpenTUI HIJACKS `console.*` and stdout (opentui skill / gotcha) —
|
||||
* logging to the terminal corrupts the rendered frame. So this NEVER touches
|
||||
* console/stdout/stderr; file + ring only. It's the single approved logging path
|
||||
* for the whole engine. Level filter via HERMES_TUI_LOG_LEVEL (default INFO).
|
||||
*/
|
||||
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { Schema } from 'effect'
|
||||
|
||||
// LogLevel is modeled schema-first (the schema-inferred-types idiom, mirroring
|
||||
// `boundary/schema/GatewayEvent.ts`): declare the literal union once and INFER
|
||||
// the TS type from it, so the two can never drift.
|
||||
export const LogLevelSchema = Schema.Literals(['debug', 'info', 'warn', 'error'])
|
||||
export type LogLevel = typeof LogLevelSchema.Type
|
||||
|
||||
const PRIORITY: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }
|
||||
|
||||
/**
|
||||
* Serialize a value to JSON that NEVER throws. A caller-supplied `data` can hold
|
||||
* a circular reference or a BigInt — plain `JSON.stringify` throws on both, which
|
||||
* (in the file-write `catch` below) would flip `fileBroken` and kill ALL file
|
||||
* logging for the session. Instead we degrade a bad payload to a placeholder:
|
||||
* - circular refs (tracked via a per-call `WeakSet` of seen objects) → '[Circular]'
|
||||
* - BigInt → `\`${n}n\`` (JSON has no bigint; keep it readable + reversible-ish)
|
||||
* and wrap the whole thing so any other throw (e.g. a hostile `toJSON`) falls back
|
||||
* to `String(value)`, then to '[unserializable]' if even that throws.
|
||||
*/
|
||||
export function safeStringify(value: unknown): string {
|
||||
try {
|
||||
const seen = new WeakSet<object>()
|
||||
return JSON.stringify(value, (_key, val: unknown) => {
|
||||
if (typeof val === 'bigint') return `${val}n`
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
if (seen.has(val)) return '[Circular]'
|
||||
seen.add(val)
|
||||
}
|
||||
return val
|
||||
})
|
||||
} catch {
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
readonly t: number // epoch ms
|
||||
readonly level: LogLevel
|
||||
readonly scope: string
|
||||
readonly msg: string
|
||||
readonly data?: unknown
|
||||
}
|
||||
|
||||
const RING_LIMIT = 2000
|
||||
|
||||
// Size-based rotation for the append-only NDJSON file (mirrors opencode's
|
||||
// keep-N model, but size- rather than time-keyed since we write one growing
|
||||
// file). When the live file crosses LOG_MAX_BYTES we shift
|
||||
// `.log` → `.log.1` → … → `.log.${LOG_KEEP}` (dropping the oldest) and resume on
|
||||
// a fresh empty `.log`. Rotation is best-effort: any failure leaves us writing
|
||||
// to the existing file (logging must never crash the engine).
|
||||
const LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
const LOG_KEEP = 5
|
||||
|
||||
function defaultLogFile(): string {
|
||||
const explicit = process.env.HERMES_TUI_LOG_FILE?.trim()
|
||||
if (explicit) return explicit
|
||||
return join(homedir(), '.hermes', 'logs', 'opentui-v2.log')
|
||||
}
|
||||
|
||||
function defaultLevel(): LogLevel {
|
||||
const raw = process.env.HERMES_TUI_LOG_LEVEL?.trim().toLowerCase()
|
||||
return raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error' ? raw : 'info'
|
||||
}
|
||||
|
||||
/** A timing span — call `.stop()` (or `using` it) to log completion + duration. */
|
||||
export interface TimeSpan {
|
||||
stop: () => void
|
||||
[Symbol.dispose]: () => void
|
||||
}
|
||||
|
||||
export class Log {
|
||||
private ring: LogEntry[] = []
|
||||
private file: string | null
|
||||
private fileBroken = false
|
||||
private minPriority: number
|
||||
// Bytes in the live log file. Seeded from statSync on open (counter approach —
|
||||
// we avoid a statSync on EVERY write); incremented by each line's byte length
|
||||
// and reset to 0 after a rotation. Rotation triggers when this would cross
|
||||
// LOG_MAX_BYTES, so the live file stays bounded without per-write fs stats.
|
||||
private fileBytes = 0
|
||||
|
||||
constructor(file: string | null = defaultLogFile(), level: LogLevel = defaultLevel()) {
|
||||
this.file = file
|
||||
this.minPriority = PRIORITY[level]
|
||||
if (this.file) {
|
||||
try {
|
||||
mkdirSync(dirname(this.file), { recursive: true })
|
||||
} catch {
|
||||
this.fileBroken = true
|
||||
}
|
||||
try {
|
||||
this.fileBytes = statSync(this.file).size
|
||||
} catch {
|
||||
this.fileBytes = 0 // no existing file (or unreadable) → start the counter at 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLevel(level: LogLevel): void {
|
||||
this.minPriority = PRIORITY[level]
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort size-based rotation: `.log.${LOG_KEEP}` is dropped, every other
|
||||
* `.log.N` shifts up, the live `.log` becomes `.log.1`, and the counter resets
|
||||
* so writing continues on a fresh file. Any fs failure is swallowed and we keep
|
||||
* writing to the existing file — rotation must never crash logging.
|
||||
*/
|
||||
private rotate(file: string): void {
|
||||
try {
|
||||
try {
|
||||
unlinkSync(`${file}.${LOG_KEEP}`)
|
||||
} catch {
|
||||
// oldest slot may not exist yet — fine
|
||||
}
|
||||
for (let i = LOG_KEEP - 1; i >= 1; i--) {
|
||||
try {
|
||||
renameSync(`${file}.${i}`, `${file}.${i + 1}`)
|
||||
} catch {
|
||||
// that slot may not exist yet — fine
|
||||
}
|
||||
}
|
||||
renameSync(file, `${file}.1`)
|
||||
this.fileBytes = 0
|
||||
} catch {
|
||||
// rotation failed (e.g. live file vanished) — leave the counter alone and
|
||||
// keep appending to the existing path; better an oversized log than none.
|
||||
}
|
||||
}
|
||||
|
||||
private write(level: LogLevel, scope: string, msg: string, data?: unknown): void {
|
||||
if (PRIORITY[level] < this.minPriority) return
|
||||
const entry: LogEntry =
|
||||
data === undefined ? { t: Date.now(), level, scope, msg } : { t: Date.now(), level, scope, msg, data }
|
||||
this.ring.push(entry)
|
||||
if (this.ring.length > RING_LIMIT) this.ring.shift()
|
||||
|
||||
if (this.file && !this.fileBroken) {
|
||||
try {
|
||||
const line = safeStringify(entry) + '\n'
|
||||
if (this.fileBytes > 0 && this.fileBytes + Buffer.byteLength(line) > LOG_MAX_BYTES) this.rotate(this.file)
|
||||
appendFileSync(this.file, line)
|
||||
this.fileBytes += Buffer.byteLength(line)
|
||||
} catch {
|
||||
this.fileBroken = true // stop hammering a broken path; the ring keeps working
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug(scope: string, msg: string, data?: unknown): void {
|
||||
this.write('debug', scope, msg, data)
|
||||
}
|
||||
info(scope: string, msg: string, data?: unknown): void {
|
||||
this.write('info', scope, msg, data)
|
||||
}
|
||||
warn(scope: string, msg: string, data?: unknown): void {
|
||||
this.write('warn', scope, msg, data)
|
||||
}
|
||||
error(scope: string, msg: string, data?: unknown): void {
|
||||
this.write('error', scope, msg, data)
|
||||
}
|
||||
|
||||
/** A logger bound to a fixed scope (opencode's tagged-logger ergonomics). */
|
||||
child(scope: string): ScopedLog {
|
||||
return new ScopedLog(this, scope)
|
||||
}
|
||||
|
||||
/** Time an operation: logs `<msg> started` now and `<msg> completed` + duration on stop. */
|
||||
time(scope: string, msg: string, data?: Record<string, unknown>): TimeSpan {
|
||||
const started = Date.now()
|
||||
this.info(scope, `${msg} started`, data)
|
||||
const stop = () => this.info(scope, `${msg} completed`, { ...data, duration_ms: Date.now() - started })
|
||||
return { stop, [Symbol.dispose]: stop }
|
||||
}
|
||||
|
||||
/** Snapshot of the in-memory ring (newest last). For a `/logs` overlay or tests. */
|
||||
tail(n = RING_LIMIT): LogEntry[] {
|
||||
return n >= this.ring.length ? [...this.ring] : this.ring.slice(this.ring.length - n)
|
||||
}
|
||||
|
||||
/** Where the file log is written (for surfacing in the UI / `/logs`). */
|
||||
get filePath(): string | null {
|
||||
return this.fileBroken ? null : this.file
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.ring = []
|
||||
}
|
||||
}
|
||||
|
||||
/** A logger with a fixed scope — forwards to the parent Log. */
|
||||
export class ScopedLog {
|
||||
constructor(
|
||||
private readonly parent: Log,
|
||||
private readonly scope: string
|
||||
) {}
|
||||
debug(msg: string, data?: unknown): void {
|
||||
this.parent.debug(this.scope, msg, data)
|
||||
}
|
||||
info(msg: string, data?: unknown): void {
|
||||
this.parent.info(this.scope, msg, data)
|
||||
}
|
||||
warn(msg: string, data?: unknown): void {
|
||||
this.parent.warn(this.scope, msg, data)
|
||||
}
|
||||
error(msg: string, data?: unknown): void {
|
||||
this.parent.error(this.scope, msg, data)
|
||||
}
|
||||
time(msg: string, data?: Record<string, unknown>): TimeSpan {
|
||||
return this.parent.time(this.scope, msg, data)
|
||||
}
|
||||
}
|
||||
|
||||
let _singleton: Log | null = null
|
||||
|
||||
/** Module-singleton logger for the live engine. Tests construct their own `new Log(null)`. */
|
||||
export function getLog(): Log {
|
||||
_singleton ??= new Log()
|
||||
return _singleton
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* memlog — in-process 1Hz memory self-sampling to NDJSON.
|
||||
*
|
||||
* The fleet-monitoring answer to "attach live-attach.sh to all 5–10 of my
|
||||
* sessions": instead of an external watcher chasing pids, every TUI session
|
||||
* logs its OWN samples when enabled, keyed by pid + boot time, into
|
||||
* `~/.hermes/logs/memwatch/`. Aggregate across sessions with
|
||||
* the tui-bench repo's `memwatch-report.mjs` (github.com/NousResearch/tui-bench).
|
||||
*
|
||||
* Gating (docs/opentui-env-flags.md): `HERMES_TUI_MEMLOG` — defaults to the
|
||||
* `HERMES_TUI_DIAGNOSTICS` master switch, individually overridable either way.
|
||||
* One `export HERMES_TUI_DIAGNOSTICS=1` in a dev's shell rc therefore covers
|
||||
* every session they ever start; regular users write nothing.
|
||||
*
|
||||
* Cost when on: one `process.memoryUsage()` + one short append per second
|
||||
* (~60 bytes/s, ~5MB/day across ten busy sessions). The interval is unref'd —
|
||||
* it never keeps the process alive. Every failure path disables the logger
|
||||
* silently (diagnostics must never break the TUI). Retention: files older
|
||||
* than 14 days are pruned at start, best-effort.
|
||||
*
|
||||
* Sample shape (one JSON object per line):
|
||||
* { t, rss_kb, heap_used_kb, external_kb, mounted, peak_mounted }
|
||||
* `mounted`/`peak_mounted` come from the windowing DEV counters
|
||||
* (logic/window.ts) — they update whenever windowing is active, independent
|
||||
* of the WINDOW_STATS exposure flag.
|
||||
*/
|
||||
import { appendFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { diagnosticsEnabled, envFlag } from '../logic/env.ts'
|
||||
import { windowRowStats } from '../logic/window.ts'
|
||||
|
||||
const RETENTION_DAYS = 14
|
||||
const SAMPLE_MS = 1000
|
||||
|
||||
function memwatchDir(): string {
|
||||
const home = process.env.HERMES_HOME?.trim()
|
||||
const base = home && home.length > 0 ? home : join(homedir(), '.hermes')
|
||||
return join(base, 'logs', 'memwatch')
|
||||
}
|
||||
|
||||
function pruneOld(dir: string): void {
|
||||
const cutoff = Date.now() - RETENTION_DAYS * 24 * 3600 * 1000
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.endsWith('.jsonl')) continue
|
||||
const p = join(dir, name)
|
||||
try {
|
||||
if (statSync(p).mtimeMs < cutoff) unlinkSync(p)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the self-sampler (no-op unless enabled). Returns a stop function. */
|
||||
export function startMemlog(): () => void {
|
||||
if (!envFlag(process.env.HERMES_TUI_MEMLOG, diagnosticsEnabled())) return () => {}
|
||||
try {
|
||||
const dir = memwatchDir()
|
||||
mkdirSync(dir, { recursive: true })
|
||||
pruneOld(dir)
|
||||
const boot = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15)
|
||||
const file = join(dir, `${boot}-${process.pid}.jsonl`)
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
const m = process.memoryUsage()
|
||||
const w = windowRowStats()
|
||||
const line = JSON.stringify({
|
||||
t: Math.floor(Date.now() / 1000),
|
||||
rss_kb: Math.floor(m.rss / 1024),
|
||||
heap_used_kb: Math.floor(m.heapUsed / 1024),
|
||||
external_kb: Math.floor(m.external / 1024),
|
||||
mounted: w.mounted,
|
||||
peak_mounted: w.peakMounted
|
||||
})
|
||||
appendFileSync(file, line + '\n')
|
||||
} catch {
|
||||
clearInterval(timer) // a failing diagnostic must not retry forever
|
||||
}
|
||||
}, SAMPLE_MS)
|
||||
timer.unref?.()
|
||||
return () => clearInterval(timer)
|
||||
} catch {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* memoryMonitor — the early-warning BOUNDARY (touches node:process; the pure
|
||||
* threshold/growth logic lives in logic/memoryMonitor.ts).
|
||||
*
|
||||
* Ports the high-value #34095 silent-death early-warning from Ink
|
||||
* (`ui-tui/src/lib/memoryMonitor.ts`) to the OpenTUI engine, and ONLY that:
|
||||
* - NO auto heap-snapshot capture (the #41948 disk-fill bug class is not
|
||||
* re-imported — the always-on memlog NDJSON trace is the diagnosis path,
|
||||
* and its rss-vs-heap divergence is the better diagnostic for the native
|
||||
* RSS-leak class a V8 snapshot captures poorly).
|
||||
* - NO Ink cache eviction (Solid disposes out-of-window rows; windowing +
|
||||
* proactiveGc already cover memory pressure).
|
||||
*
|
||||
* It polls `process.memoryUsage()` on a 10s unref'd interval and, when the
|
||||
* pure detector fires, surfaces a single transcript system line so the user
|
||||
* SEES "memory climbing fast" before Node OOMs under the exit threshold. This
|
||||
* is ON by default (unlike memlog/heapdump): it's a user-facing safety
|
||||
* heads-up, not a diagnostic dump, and costs one memoryUsage() read per 10s
|
||||
* with zero disk. Every failure path disables silently (a diagnostic must
|
||||
* never break the TUI — the one place the "errors propagate" rule is
|
||||
* intentionally inverted, matching memlog/proactiveGc).
|
||||
*/
|
||||
import { createWarnState, evaluateWarn, warnLine } from '../logic/memoryMonitor.ts'
|
||||
|
||||
/** Sample cadence — matches Ink's monitor (10s, unref'd). */
|
||||
const SAMPLE_MS = 10_000
|
||||
|
||||
/**
|
||||
* Start the early-warning watcher. `emitWarn` receives the ready-to-show system
|
||||
* line on the (one-shot) tick growth looks abnormal. Returns a stop function.
|
||||
* The interval is unref'd so it never keeps the process alive.
|
||||
*/
|
||||
export function startMemoryMonitor(emitWarn: (line: string) => void): () => void {
|
||||
const state = createWarnState()
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
const { heapUsed, rss } = process.memoryUsage()
|
||||
const { fire, growthBytes } = evaluateWarn(state, heapUsed)
|
||||
if (fire) emitWarn(warnLine(heapUsed, rss, growthBytes))
|
||||
} catch {
|
||||
clearInterval(timer) // a failing diagnostic must not retry forever
|
||||
}
|
||||
}, SAMPLE_MS)
|
||||
timer.unref?.()
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Multi-click selection — double-click selects the word, triple-click the
|
||||
* line, drag after either extends by word/line with the clicked span held
|
||||
* (boundary shim in the ffiSafe.ts / nativeHandles.ts mold).
|
||||
*
|
||||
* Why a shim: @opentui/core's renderer knows only press-drag character
|
||||
* selection — `processSingleMouseEvent` calls `startSelection(renderable,x,y)`
|
||||
* on a fresh left press and `updateSelection(renderable,x,y)` per drag step,
|
||||
* with no click-count concept. Wrapping those two INSTANCE methods is the
|
||||
* narrowest seam that adds multi-click without forking core: the press wrapper
|
||||
* counts clicks (Ink's 500ms / 1-cell chain) and, on a multi-click, seeds the
|
||||
* selection with the word/line span instead of a point; the drag wrapper snaps
|
||||
* the focus to word/line bounds and flips the selection anchor to whichever
|
||||
* end of the held span faces away from the pointer.
|
||||
*
|
||||
* Word/line bounds come from the presented frame (`currentRenderBuffer`'s
|
||||
* char grid — the same buffer `captureCharFrame` reads in tests), so what
|
||||
* highlights is exactly the run of characters the user sees. All wrapped paths
|
||||
* degrade to core's plain character selection when anything is off (no
|
||||
* buffer, destroyed renderer, out-of-bounds click) — selection must never
|
||||
* throw out of the mouse pipeline.
|
||||
*/
|
||||
import type { CliRenderer } from '@opentui/core'
|
||||
|
||||
import type { AnchorSpan, Point, ScreenText } from '../logic/multiClick.ts'
|
||||
import { comparePoints, createClickCounter, extendedSelection, lineSpanAt, wordSpanAt } from '../logic/multiClick.ts'
|
||||
|
||||
/** The renderable surface the shim needs (anchor tracking reads live x/y). */
|
||||
interface AnchorRenderable {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** The private renderer surface the shim wraps (runtime-verified shapes). */
|
||||
interface RendererSeam {
|
||||
startSelection(renderable: AnchorRenderable, x: number, y: number): void
|
||||
updateSelection(
|
||||
renderable: AnchorRenderable | undefined,
|
||||
x: number,
|
||||
y: number,
|
||||
options?: { finishDragging?: boolean }
|
||||
): void
|
||||
currentRenderBuffer: {
|
||||
width: number
|
||||
height: number
|
||||
buffers: { char: Uint32Array }
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapt the presented frame to the pure logic's ScreenText; null when the
|
||||
* buffer is unreadable (mid-teardown/resize) → degrade to char selection. */
|
||||
function presentedFrame(seam: RendererSeam): ScreenText | null {
|
||||
try {
|
||||
const buffer = seam.currentRenderBuffer
|
||||
const chars = buffer.buffers.char
|
||||
const width = buffer.width
|
||||
if (width <= 0 || buffer.height <= 0) return null
|
||||
return {
|
||||
width,
|
||||
height: buffer.height,
|
||||
codepointAt: (x, y) => chars[y * width + x] ?? 0
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native selection semantics (probed empirically, scratch test 2026-06-11):
|
||||
* per-renderable native selection keeps the anchor from the initial
|
||||
* `setLocalSelection` — the anchor args of later `updateLocalSelection` calls
|
||||
* are IGNORED, so moving the anchor requires restarting the selection. And the
|
||||
* selection is caret-style at the focus end: a forward selection covers cells
|
||||
* `[anchor, focus)` (focus cell excluded) while a backward one covers
|
||||
* `[focus, anchor]` (both included). Inclusive cell spans therefore translate
|
||||
* to: forward focus = `hi + 1`, backward focus = `lo` exactly.
|
||||
*/
|
||||
function forwardFocusX(anchor: Point, focus: Point): number {
|
||||
return comparePoints(focus, anchor) >= 0 ? focus.x + 1 : focus.x
|
||||
}
|
||||
|
||||
/** Install the multi-click wrappers on a live renderer instance. */
|
||||
export function installMultiClickSelection(renderer: CliRenderer): void {
|
||||
const seam = renderer as unknown as RendererSeam
|
||||
const nextClickCount = createClickCounter()
|
||||
|
||||
// The held span while a multi-click selection is live: cleared by the next
|
||||
// single click (which starts a plain char selection). `anchor` mirrors the
|
||||
// selection's current anchor end so drag steps only rebind it on a flip.
|
||||
let held: { span: AnchorSpan; renderable: AnchorRenderable; anchor: Point } | null = null
|
||||
|
||||
const coreStart = seam.startSelection.bind(renderer)
|
||||
const coreUpdate = seam.updateSelection.bind(renderer)
|
||||
|
||||
seam.startSelection = (renderable, x, y) => {
|
||||
held = null
|
||||
const clicks = nextClickCount(x, y, Date.now())
|
||||
const screen = clicks >= 2 ? presentedFrame(seam) : null
|
||||
const span = screen ? (clicks === 2 ? wordSpanAt(screen, x, y) : lineSpanAt(screen, y)) : null
|
||||
if (!span) {
|
||||
coreStart(renderable, x, y)
|
||||
return
|
||||
}
|
||||
// Seed anchor at the span start, focus past its end (forward caret) — one
|
||||
// start+update pair, exactly the calls a real press-then-drag would make.
|
||||
coreStart(renderable, span.lo.x, span.lo.y)
|
||||
coreUpdate(renderable, span.hi.x + 1, span.hi.y)
|
||||
held = {
|
||||
span: { ...span, kind: clicks === 2 ? 'word' : 'line' },
|
||||
renderable,
|
||||
anchor: span.lo
|
||||
}
|
||||
}
|
||||
|
||||
seam.updateSelection = (renderable, x, y, options) => {
|
||||
const screen = held ? presentedFrame(seam) : null
|
||||
if (!held || !screen) {
|
||||
coreUpdate(renderable, x, y, options)
|
||||
return
|
||||
}
|
||||
const { anchor, focus } = extendedSelection(held.span, screen, x, y)
|
||||
if (anchor.x !== held.anchor.x || anchor.y !== held.anchor.y) {
|
||||
// The anchor end flipped across the held span — native selection anchors
|
||||
// are fixed at set time (see forwardFocusX note), so restart it there.
|
||||
coreStart(held.renderable, anchor.x, anchor.y)
|
||||
held = { ...held, anchor }
|
||||
}
|
||||
coreUpdate(renderable, forwardFocusX(anchor, focus), focus.y, options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Native handle-table exhaustion safety for @opentui/core 0.4.0 — sibling of
|
||||
* the ffiSafe.ts coordinate shim (same class of fix: harden OUR side of the
|
||||
* Node-FFI seam, TODO(upstream) to delete).
|
||||
*
|
||||
* Root cause (bench crash: every otui mem3000 cell died at ≈3000 lumpy fixture
|
||||
* messages, exit 7, ~880MB RSS — far below the 2GB cgroup cap): the native
|
||||
* core indexes EVERY object — TextBuffer, TextBufferView, SyntaxStyle,
|
||||
* OptimizedBuffer, … — through ONE global handle registry with 16-bit slot
|
||||
* indices (core `src/zig/handles.zig`: `INDEX_BITS = 16` → `MAX_SLOTS = 65535`,
|
||||
* slot 0 reserved). Measured on this install: exactly 65,534 live handles, the
|
||||
* 65,535th `createSyntaxStyle()` fails; `destroy()` does recycle slots, so
|
||||
* exhaustion means LIVE objects.
|
||||
*
|
||||
* Every `TextBufferRenderable` burns THREE slots at construction
|
||||
* (`TextBufferRenderable.ts:77-80`: `TextBuffer.create()` +
|
||||
* `TextBufferView.create()` + `SyntaxStyle.create()`). The mount-everything
|
||||
* transcript hits the wall at ≈1,400 store rows (≈21.8k text renderables ×3 ≈
|
||||
* 65.5k handles): the next mount throws `Failed to create SyntaxStyle`
|
||||
* (zig.ts:4554) out of a Solid mount effect → uncaught → the renderer's OWN
|
||||
* `uncaughtException` handler (renderer.ts `handleError`) calls
|
||||
* `console.show()`, which allocates the console-overlay `OptimizedBuffer` —
|
||||
* needing ANOTHER slot — so the handler itself throws `Failed to create
|
||||
* optimized buffer: WxH` and Node dies with exit 7 (fatal error in the
|
||||
* uncaughtException handler), MASKING the real error. (The exception-handler
|
||||
* guard lives in renderer.ts `guardRendererErrorHandlers`.)
|
||||
*
|
||||
* Why we can't just SHARE one SyntaxStyle across renderables (the obvious
|
||||
* 3→2 fix): the per-buffer style is load-bearing. The native styled-text path
|
||||
* (text-buffer.zig `setStyledText`) registers each chunk's color by NAME —
|
||||
* "chunk0", "chunk1", … — into the buffer's OWN syntax style, and
|
||||
* registration is name-keyed-overwrite (syntax-style.zig `putStyle`: existing
|
||||
* name → overwrite that id's definition). A shared style would have every
|
||||
* styled `<text>` overwrite every other one's chunk colors (live highlights
|
||||
* reference style IDS, re-resolved at render). So pooling is unsound at our
|
||||
* layer; the table pressure itself is bounded by the store row cap
|
||||
* (logic/store.ts, clamped to a handle-safe ceiling) until #27 lands
|
||||
* renderable-weight-aware capping/virtualization.
|
||||
*
|
||||
* What THIS shim does: makes style allocation failure DEGRADE instead of
|
||||
* throwing out of mount/render. `SyntaxStyle.create()` on a full table
|
||||
* returns a DETACHED style (handle 0 = the native INVALID_HANDLE):
|
||||
* - JS-side styling still works — markdown/code chunk colors come from
|
||||
* `getStyle`/`mergeStyles`, which read the instance's JS `styleDefs` map
|
||||
* (see core lib/tree-sitter-styled-text.ts), never the native handle;
|
||||
* - every native call on handle 0 is already a safe no-op in zig (acquire
|
||||
* fails → early return), and `textBuffer.setSyntaxStyle(detached)` passes
|
||||
* ptr 0 which the native side treats as "no style" — buffer-level styled
|
||||
* -text highlights are skipped, i.e. that text renders unstyled;
|
||||
* - `destroy()` on a detached style is a native no-op (beginDestroy(0)).
|
||||
*
|
||||
* TODO(upstream): file an OpenTUI issue — (a) a global 64k handle table with a
|
||||
* 3-slot cost per text renderable is too small for transcript-style TUIs;
|
||||
* (b) allocation failure throws out of the render loop with no degrade path;
|
||||
* (c) `handleError` allocates (console overlay) and so crashes on the very
|
||||
* condition it is reporting, masking the root cause with exit 7.
|
||||
*/
|
||||
import { SyntaxStyle, resolveRenderLib, type SyntaxStyleHandle } from '@opentui/core'
|
||||
|
||||
import { getLog } from './log.ts'
|
||||
|
||||
/** The native side's INVALID_HANDLE — every FFI entry point no-ops on it. */
|
||||
const DETACHED: SyntaxStyleHandle = 0 as never
|
||||
|
||||
let installed = false
|
||||
let warnedExhausted = false
|
||||
|
||||
/** Build a SyntaxStyle backed by NO native handle: JS-side styleDefs/merge
|
||||
* caches fully functional, all native calls safe no-ops (handle 0). */
|
||||
function detachedSyntaxStyle(): SyntaxStyle {
|
||||
return new SyntaxStyle(resolveRenderLib(), DETACHED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `SyntaxStyle.create` (the static the core's own TextBufferRenderable
|
||||
* constructor calls — @opentui/core is external, one shared class object) so
|
||||
* native handle-table exhaustion degrades to a detached, unstyled-but-inert
|
||||
* style instead of throwing out of a Solid mount effect. Idempotent.
|
||||
*
|
||||
* @param factory test seam — inject a failing allocator to exercise the
|
||||
* degrade path (defaults to the real `SyntaxStyle.create`).
|
||||
*/
|
||||
export function installSyntaxStyleDegrade(factory?: () => SyntaxStyle): void {
|
||||
if (installed) return
|
||||
installed = true
|
||||
|
||||
const origCreate = factory ?? SyntaxStyle.create.bind(SyntaxStyle)
|
||||
|
||||
SyntaxStyle.create = function create(): SyntaxStyle {
|
||||
try {
|
||||
return origCreate()
|
||||
} catch (cause) {
|
||||
if (!warnedExhausted) {
|
||||
warnedExhausted = true
|
||||
try {
|
||||
getLog().error(
|
||||
'native',
|
||||
'SyntaxStyle allocation failed — native handle table exhausted; degrading to unstyled',
|
||||
{
|
||||
cause: String(cause)
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// logging is best-effort inside a degrade path
|
||||
}
|
||||
}
|
||||
return detachedSyntaxStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"comment": "Tree-sitter grammars beyond @opentui/core's bundled 5 (ts/js/markdown/markdown_inline/zig). NOT vendored — declared as remote URLs and fetched+cached at runtime by OpenTUI's TreeSitterClient into HERMES_TUI_PARSER_CACHE (see src/boundary/parsers.ts). This file is the URL source of truth. Grammar versions are pinned via the release tag in each wasm URL; .scm highlight queries follow opencode's per-language source choices (parser-repo queries for python/html where nvim-treesitter's are parser-incompatible, nvim-treesitter master otherwise). cpp deliberately dropped (3.28 MB, ~half the old vendored bundle). Aliases are belt-and-braces: core's extToFiletype/infoStringToFiletype already normalize py->python etc., but a literal alias filetype reaching the client still resolves. To add/refresh a grammar, edit the wasm tag + the highlights URL here — no binaries land in the repo.",
|
||||
"parsers": [
|
||||
{
|
||||
"filetype": "python",
|
||||
"aliases": ["py"],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
|
||||
"highlights": "https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "rust",
|
||||
"aliases": ["rs"],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.23.2/tree-sitter-rust.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "go",
|
||||
"aliases": [],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.23.4/tree-sitter-go.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "bash",
|
||||
"aliases": ["sh", "shell", "zsh"],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.23.3/tree-sitter-bash.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "json",
|
||||
"aliases": [],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "c",
|
||||
"aliases": ["h"],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.23.5/tree-sitter-c.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "html",
|
||||
"aliases": [],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
|
||||
"highlights": "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "css",
|
||||
"aliases": [],
|
||||
"wasm": "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.23.2/tree-sitter-css.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "yaml",
|
||||
"aliases": ["yml"],
|
||||
"wasm": "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.1/tree-sitter-yaml.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm"
|
||||
},
|
||||
{
|
||||
"filetype": "toml",
|
||||
"aliases": [],
|
||||
"wasm": "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
|
||||
"highlights": "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/toml/highlights.scm"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Extra Tree-sitter grammar registration — the syntax-highlighting language
|
||||
* expansion (docs/plans/opentui-syntax-highlighting-languages.md).
|
||||
*
|
||||
* @opentui/core@0.4.x bundles only a handful of grammars (ts/js/markdown/
|
||||
* markdown_inline/zig); everything else renders plain text. The cure is the
|
||||
* public `addDefaultParsers()` API fed with REMOTE grammar URLs — OpenTUI's
|
||||
* TreeSitterClient fetches each `.wasm`/`.scm` lazily on first use of a
|
||||
* filetype and caches it under the client's `dataPath`. We do NOT vendor any
|
||||
* binaries (cf. opencode, which checks in zero `.wasm`/`.scm` and lets OpenTUI
|
||||
* fetch+cache). The grammar set + its URLs live in `parsers.manifest.json`.
|
||||
*
|
||||
* Cache location: `HERMES_TUI_PARSER_CACHE` (set by the Python launcher to
|
||||
* `~/.hermes/cache/opentui-parsers/`, profile-aware via get_hermes_home). When
|
||||
* unset (dev/demo/CI), we leave OpenTUI's default data path
|
||||
* (`$XDG_DATA_HOME/opentui` → `~/.local/share/opentui`) untouched.
|
||||
*
|
||||
* `setDataPath()` on the GLOBAL client must run BEFORE the client initializes
|
||||
* (it only mutates `options.dataPath` until init, then the worker boots with
|
||||
* it). `addDefaultParsers()` must run BEFORE the first `<code>`/`<markdown>`
|
||||
* mount (they grab the global client lazily and trigger init). The entry
|
||||
* imports + calls `registerRemoteParsers()` at module load, ahead of renderer
|
||||
* acquisition, so both orderings hold.
|
||||
*
|
||||
* Offline behavior: registration itself does NO network (it only declares the
|
||||
* URL configs). The fetch happens on first highlight of a given language; if it
|
||||
* fails (air-gapped / GitHub unreachable), OpenTUI degrades that filetype to
|
||||
* plain text — never a throw. A registration error likewise degrades the whole
|
||||
* extra set to plain text.
|
||||
*/
|
||||
import { getTreeSitterClient } from '@opentui/core'
|
||||
import { addDefaultParsers } from '@opentui/core'
|
||||
|
||||
import manifest from './parsers.manifest.json'
|
||||
import { getLog } from './log.ts'
|
||||
|
||||
interface ManifestParser {
|
||||
readonly filetype: string
|
||||
readonly aliases: readonly string[]
|
||||
readonly wasm: string
|
||||
readonly highlights: string
|
||||
}
|
||||
|
||||
/** The registered parser configs (exported shape for tests/diagnostics). */
|
||||
export interface RegisteredParser {
|
||||
filetype: string
|
||||
aliases?: string[]
|
||||
wasm: string
|
||||
queries: { highlights: string[] }
|
||||
}
|
||||
|
||||
/** The cache dir for fetched grammar assets, or undefined to use OpenTUI's
|
||||
* default ($XDG_DATA_HOME/opentui). The launcher sets this per-profile. */
|
||||
export function parserCacheDir(): string | undefined {
|
||||
const dir = (process.env.HERMES_TUI_PARSER_CACHE ?? '').trim()
|
||||
return dir.length ? dir : undefined
|
||||
}
|
||||
|
||||
/** Build the remote parser configs from the manifest. Pure — no network, no
|
||||
* filesystem; just declares the URL configs OpenTUI fetches lazily. */
|
||||
export function remoteParsers(): RegisteredParser[] {
|
||||
const configs: RegisteredParser[] = []
|
||||
for (const parser of (manifest as { parsers: ManifestParser[] }).parsers) {
|
||||
if (!parser.wasm || !parser.highlights) continue
|
||||
configs.push({
|
||||
filetype: parser.filetype,
|
||||
...(parser.aliases.length ? { aliases: [...parser.aliases] } : {}),
|
||||
wasm: parser.wasm,
|
||||
queries: { highlights: [parser.highlights] }
|
||||
})
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
/** Point the global tree-sitter client's cache at our profile dir, then
|
||||
* register the remote grammars with core's global default-parser list.
|
||||
* Returns what was registered (empty on any failure — plain-text fallback). */
|
||||
export function registerRemoteParsers(): RegisteredParser[] {
|
||||
try {
|
||||
const cache = parserCacheDir()
|
||||
if (cache) {
|
||||
// Must precede the client's lazy initialize() (first <code>/<markdown>
|
||||
// mount). Pre-init this only mutates options.dataPath; the returned
|
||||
// promise resolves immediately (no worker yet) so we don't await it.
|
||||
void getTreeSitterClient().setDataPath(cache)
|
||||
}
|
||||
const parsers = remoteParsers()
|
||||
if (!parsers.length) {
|
||||
getLog().warn('parsers', 'no remote tree-sitter grammars declared — extras render plain', {})
|
||||
return []
|
||||
}
|
||||
addDefaultParsers(parsers)
|
||||
return parsers
|
||||
} catch (cause) {
|
||||
getLog().warn('parsers', 'tree-sitter registration failed — extras render plain', {
|
||||
cause: String(cause)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* proactiveGc — opt-in, idle-gated `global.gc()` for the low-mem path (W2).
|
||||
*
|
||||
* GATED on the low-mem opt-in: only runs when the user set a LOW
|
||||
* `HERMES_TUI_HEAP_MB` (the same knob W1 threads into `--max-old-space-size`).
|
||||
* Default / unconstrained sessions do NOTHING — no proactive GC, no divergence
|
||||
* from Ink on the default path (Ink never calls gc proactively; it only exposes
|
||||
* it for heapdumps, so W2 is OpenTUI-only by design — spec D5).
|
||||
*
|
||||
* TRIGGER MODEL — idle-gated, never mid-stream:
|
||||
* - A low-frequency timer ticks every IDLE_MS. On each tick it calls
|
||||
* `global.gc()` ONLY when (a) a turn is NOT streaming (`isStreaming()` false
|
||||
* — so we never pause mid-render/mid-reply) and (b) at least one full idle
|
||||
* window has passed since the last activity (stream end / explicit touch).
|
||||
* - If RSS crosses RSS_EAGER_KB (>400MB) the cadence tightens (EAGER_MS) but
|
||||
* it STILL waits for idle — eagerness shortens the window, never bypasses it.
|
||||
* - `--expose-gc` (W1) makes `global.gc` real; without it this is a silent
|
||||
* no-op (we detect and disable). The timer is unref'd — it never keeps the
|
||||
* process alive — and every failure path disables silently (a GC helper must
|
||||
* never break the TUI).
|
||||
*
|
||||
* Reuses `process.memoryUsage().rss` (same read as memlog) for the >400MB check.
|
||||
*/
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
|
||||
/** Below this heap cap (MB) we treat the session as low-mem opt-in. 8192 is the
|
||||
* default; anyone who set a cap materially under it wants tight memory. */
|
||||
const LOW_MEM_HEAP_MB = 4096
|
||||
/** Idle window (ms): time since last activity before a GC is allowed. */
|
||||
const IDLE_MS = 8000
|
||||
/** Tightened idle window once RSS is high. */
|
||||
const EAGER_MS = 3000
|
||||
/** RSS (KB) above which GC becomes eager (still idle-gated). 400MB. */
|
||||
const RSS_EAGER_KB = 400 * 1024
|
||||
|
||||
/** The configured heap cap in MB from the W1 knob, or null when unset/garbage.
|
||||
* The Python launcher reads the same env; the child inherits it, so the Node
|
||||
* side can read it directly to know whether low-mem mode is active. */
|
||||
function configuredHeapMb(): number | null {
|
||||
const v = (process.env.HERMES_TUI_HEAP_MB ?? '').trim()
|
||||
if (!/^\d+$/.test(v)) return null
|
||||
const n = Number.parseInt(v, 10)
|
||||
return n > 0 ? n : null
|
||||
}
|
||||
|
||||
/** Whether proactive GC should run: a low heap cap is set AND gc is exposed.
|
||||
* `HERMES_TUI_PROACTIVE_GC` can force it on/off, but defaults to the low-mem
|
||||
* signal so the knob composes (spec D9: independent knobs). */
|
||||
export function proactiveGcEnabled(heapMb: number | null = configuredHeapMb()): boolean {
|
||||
const lowMem = heapMb !== null && heapMb <= LOW_MEM_HEAP_MB
|
||||
return envFlag(process.env.HERMES_TUI_PROACTIVE_GC, lowMem) && typeof global.gc === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the idle-gated proactive GC watcher. `isStreaming` reports whether a
|
||||
* turn is mid-flight (read from the store's `info.running`). Returns a stop
|
||||
* function and a `touch()` to mark fresh activity (e.g. on keypress / stream
|
||||
* start) so the idle clock resets. No-op (returns inert handles) when disabled.
|
||||
*/
|
||||
export function startProactiveGc(isStreaming: () => boolean): { stop: () => void; touch: () => void } {
|
||||
if (!proactiveGcEnabled()) return { stop: () => {}, touch: () => {} }
|
||||
const gc = global.gc
|
||||
if (typeof gc !== 'function') return { stop: () => {}, touch: () => {} }
|
||||
|
||||
let lastActivity = Date.now()
|
||||
const touch = () => {
|
||||
lastActivity = Date.now()
|
||||
}
|
||||
|
||||
// Tick at the eager cadence; the idle-window check (below) does the real
|
||||
// gating, so a high-RSS session reacts within EAGER_MS while a calm one still
|
||||
// waits the full IDLE_MS. One cheap rss read + compare per tick.
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
if (isStreaming()) {
|
||||
// mid-stream: defer entirely and keep the clock fresh so a GC can't fire
|
||||
// the instant the stream ends — it waits a full idle window after.
|
||||
lastActivity = Date.now()
|
||||
return
|
||||
}
|
||||
const rssKb = Math.floor(process.memoryUsage().rss / 1024)
|
||||
const window = rssKb > RSS_EAGER_KB ? EAGER_MS : IDLE_MS
|
||||
if (Date.now() - lastActivity < window) return
|
||||
gc()
|
||||
// After a collection, reset the clock so we don't GC every tick — the next
|
||||
// one waits another full idle window.
|
||||
lastActivity = Date.now()
|
||||
} catch {
|
||||
clearInterval(timer) // a failing GC helper must not retry forever
|
||||
}
|
||||
}, EAGER_MS)
|
||||
timer.unref?.()
|
||||
|
||||
return { stop: () => clearInterval(timer), touch }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Renderer lifecycle — the Effect-side resource boundary (spec v4 §3.1).
|
||||
*
|
||||
* `acquireRelease(createCliRenderer)` so the renderer is always destroyed on
|
||||
* scope exit; a `Deferred` resolved on the renderer's "destroy" event lets the
|
||||
* entry block until the user quits. Mirrors opencode `app.tsx:177` /
|
||||
* `:185-225`.
|
||||
*
|
||||
* No throw / try-catch here: acquisition failure surfaces as a typed
|
||||
* `RendererError` via `Effect.tryPromise`'s `catch`.
|
||||
*/
|
||||
import { createCliRenderer, type CliRenderer, type KeyEvent, type Selection } from '@opentui/core'
|
||||
import { Deferred, Effect } from 'effect'
|
||||
|
||||
import { RendererError } from './errors.ts'
|
||||
import { installFfiCoordSafety } from './ffiSafe.ts'
|
||||
import { getLog } from './log.ts'
|
||||
import { installMultiClickSelection } from './multiClickSelect.ts'
|
||||
import { installSyntaxStyleDegrade } from './nativeHandles.ts'
|
||||
|
||||
// Node-FFI seam: clamp negative draw coordinates BEFORE the u32 FFI marshaling
|
||||
// (see ffiSafe.ts — scrolled-out <diff> line backgrounds crashed the render loop).
|
||||
installFfiCoordSafety()
|
||||
// Native handle-table seam: SyntaxStyle allocation failure (global 65,534-slot
|
||||
// registry exhausted) degrades to an unstyled detached style instead of throwing
|
||||
// out of a Solid mount effect (see nativeHandles.ts for the full root cause).
|
||||
installSyntaxStyleDegrade()
|
||||
|
||||
/**
|
||||
* The text a finished selection copies: the RENDERED text the user highlighted,
|
||||
* verbatim (`getSelectedText()` does correct same-line merging). Markdown markers
|
||||
* are concealed in the pretty render, so a partial selection cannot recover source —
|
||||
* this copies exactly what was highlighted (the `/copy` command gives full source).
|
||||
* Total by construction — a copy must NEVER throw out of an input/event handler
|
||||
* (that would tear down the render loop).
|
||||
*/
|
||||
function selectionCopyText(selection: Selection): string {
|
||||
try {
|
||||
return selection.getSelectedText()
|
||||
} catch (cause) {
|
||||
getLog().warn('copy', 'getSelectedText failed', { cause: String(cause) })
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export interface RendererOptions {
|
||||
/** Mouse tracking on/off (from decoded display config). */
|
||||
readonly mouse: boolean
|
||||
/** When true, a blocking prompt owns Ctrl+C (cancel) — the global quit is suppressed (gotcha §8 #6). */
|
||||
readonly isBlocked?: () => boolean
|
||||
/**
|
||||
* Ctrl+C handler (item 11). When set, it OWNS Ctrl+C while not blocked — the
|
||||
* entry's state machine decides interrupt-the-turn vs quit. When omitted, the
|
||||
* default is an immediate `renderer.destroy()` (quit).
|
||||
*/
|
||||
readonly onCtrlC?: () => void
|
||||
/**
|
||||
* Copy a mouse selection (item 1). When there's a live selection, Ctrl+C copies
|
||||
* it (this callback) instead of interrupting/quitting — opencode's selection
|
||||
* key precedence (`app.tsx:388`). Receives the rendered text the user highlighted.
|
||||
*/
|
||||
readonly onCopySelection?: (text: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a CliRenderer inside the current scope and register its release.
|
||||
* Returns the renderer plus a Deferred that resolves when the renderer is
|
||||
* destroyed (user quit) — `await` it to keep the entry alive.
|
||||
*/
|
||||
export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: RendererOptions) {
|
||||
const renderer = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
// Snapshot process error listeners so we can guard exactly the ones the
|
||||
// renderer installs (its `handleError` — see guardRendererErrorHandlers).
|
||||
const preexisting = snapshotErrorListeners()
|
||||
const created = await createCliRenderer({
|
||||
// Root canvas: TRANSPARENT by default — the terminal's own background
|
||||
// shows through (do not paint a "default dark" canvas; glitch hated
|
||||
// it). A skin's explicit ui_bg lands reactively via the header's
|
||||
// theme effect (view/header.tsx setBackgroundColor).
|
||||
// scrollbox clips growing output → no terminal-scrollback corruption (gotcha §8 #2).
|
||||
externalOutputMode: 'passthrough',
|
||||
targetFps: 60,
|
||||
// Don't let core's uncaught-error handler call the ALLOCATING
|
||||
// `console.show()` (0.4.1 public option; defaults true-in-dev). That
|
||||
// call needs a native handle, so under handle-table exhaustion — the
|
||||
// very condition being reported — it throws and exit-7-masks the
|
||||
// original error (the bench mem3000 postmortem). Disabling it removes
|
||||
// that failure mode at the source; `guardRendererErrorHandlers` below
|
||||
// stays as belt-and-suspenders (honest logging if any core handler
|
||||
// still throws), but is no longer load-bearing for the exit-7 mask.
|
||||
openConsoleOnError: false,
|
||||
// prompts own Ctrl+C → deny/cancel (gotcha §8 #6); the global quit is gated on !blocked.
|
||||
exitOnCtrlC: false,
|
||||
// OpenTUI's default exitSignals include SIGPIPE + SIGBUS, and its handler
|
||||
// calls renderer.destroy() — so a broken clipboard pipe (writeClipboard
|
||||
// spawning xclip/wl-copy that dies) raises SIGPIPE and QUITS THE TUI on
|
||||
// copy. SIGPIPE/SIGBUS are not shutdown intents; restrict to the genuine
|
||||
// termination signals so a stray pipe error can never tear down the UI.
|
||||
exitSignals: ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP'],
|
||||
useKittyKeyboard: {},
|
||||
useMouse: options.mouse
|
||||
})
|
||||
guardRendererErrorHandlers(created, preexisting)
|
||||
// Editor-grade mouse selection: double-click word, triple-click line,
|
||||
// drag extends with the clicked span held (see multiClickSelect.ts).
|
||||
installMultiClickSelection(created)
|
||||
return created
|
||||
},
|
||||
catch: cause => new RendererError({ cause })
|
||||
}),
|
||||
renderer => Effect.sync(() => destroyRenderer(renderer))
|
||||
)
|
||||
|
||||
const shutdown = yield* Deferred.make<void>()
|
||||
renderer.once('destroy', () => {
|
||||
Deferred.doneUnsafe(shutdown, Effect.void)
|
||||
})
|
||||
|
||||
// Global quit on Ctrl+C. `exitOnCtrlC:false` hands Ctrl+C to us as a key event
|
||||
// (not SIGINT), so destroying here fires 'destroy' → resolves `shutdown` → the
|
||||
// entry scope closes → finalizers run: renderer teardown + the gateway layer's
|
||||
// `client.stop()` EOFs the Python child's stdin so it exits (no orphan). When a
|
||||
// blocking prompt is up, it owns Ctrl+C (→ deny/cancel) so we suppress the quit
|
||||
// (gotcha §8 #6) — the prompt's own handler sends the cancel reply.
|
||||
const isBlocked = options.isBlocked ?? (() => false)
|
||||
renderer.keyInput.on('keypress', (key: KeyEvent) => {
|
||||
if (!(key.ctrl && key.name === 'c') || renderer.isDestroyed) return
|
||||
// Copy a live mouse selection first (item 1) — takes precedence over the
|
||||
// interrupt/quit machine and over a blocking prompt's cancel.
|
||||
if (options.onCopySelection) {
|
||||
const selection = renderer.getSelection()
|
||||
const text = selection ? selectionCopyText(selection) : ''
|
||||
if (text) {
|
||||
options.onCopySelection(text)
|
||||
renderer.clearSelection()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (isBlocked()) return // a blocking prompt owns Ctrl+C (→ deny/cancel)
|
||||
if (options.onCtrlC) options.onCtrlC()
|
||||
else renderer.destroy()
|
||||
})
|
||||
|
||||
// Copy-on-select (item 1 parity with free-code/Ink): the renderer's "selection"
|
||||
// event fires ONCE when a free-form mouse selection COMPLETES (drag finish);
|
||||
// auto-copy the spanned selectable text. Unlike the Ctrl+C path above we do NOT
|
||||
// clearSelection() — the highlight persists so the user sees what was copied and
|
||||
// Ctrl+C still works on it. `writeClipboard` is idempotent, so both paths writing
|
||||
// the same text is harmless (no double-write bug). `CliRenderer extends
|
||||
// EventEmitter`, so `on('selection', …)` is untyped → annotate `selection`.
|
||||
const onCopy = options.onCopySelection
|
||||
if (onCopy) {
|
||||
renderer.on('selection', (selection: Selection) => {
|
||||
const text = selectionCopyText(selection)
|
||||
if (text) onCopy(text)
|
||||
})
|
||||
}
|
||||
|
||||
return { renderer, shutdown } as const
|
||||
})
|
||||
|
||||
/** Best-effort renderer teardown; never throws out of the finalizer. */
|
||||
function destroyRenderer(renderer: CliRenderer): void {
|
||||
try {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
} catch {
|
||||
// teardown is best-effort; a failed destroy must not mask the real exit cause.
|
||||
}
|
||||
}
|
||||
|
||||
// ── honest-crash guard for the renderer's process error handlers ─────────────
|
||||
//
|
||||
// CliRenderer installs its own `uncaughtException`/`unhandledRejection` handler
|
||||
// (`handleError`: console.error + console.show()). `console.show()` ALLOCATES —
|
||||
// the console-overlay OptimizedBuffer needs a native handle — so under native
|
||||
// handle-table exhaustion (the very condition being reported, see
|
||||
// nativeHandles.ts) the handler itself throws `Failed to create optimized
|
||||
// buffer: WxH`, and Node kills the process with exit 7, MASKING the original
|
||||
// error (this is exactly the bench mem3000 postmortem). Wrap the listeners the
|
||||
// renderer added so a handler failure is logged honestly and the original
|
||||
// error stays the story; while the renderer is alive the process keeps running
|
||||
// (core's own contract: handled uncaught exceptions don't exit).
|
||||
|
||||
type ProcessErrorEvent = 'uncaughtException' | 'unhandledRejection'
|
||||
const PROCESS_ERROR_EVENTS: readonly ProcessErrorEvent[] = ['uncaughtException', 'unhandledRejection']
|
||||
type ErrorListener = (...args: unknown[]) => void
|
||||
|
||||
// Node's typings don't accept the union event name in listeners/on/removeListener
|
||||
// overloads — view the process emitter through a minimal untyped seam.
|
||||
const proc = process as unknown as {
|
||||
listeners(event: ProcessErrorEvent): ErrorListener[]
|
||||
on(event: ProcessErrorEvent, listener: ErrorListener): void
|
||||
removeListener(event: ProcessErrorEvent, listener: ErrorListener): void
|
||||
}
|
||||
|
||||
function snapshotErrorListeners(): ReadonlyMap<ProcessErrorEvent, ReadonlySet<unknown>> {
|
||||
return new Map(PROCESS_ERROR_EVENTS.map(event => [event, new Set(proc.listeners(event))]))
|
||||
}
|
||||
|
||||
/** Re-wrap the error listeners `createCliRenderer` added (delta vs the snapshot)
|
||||
* so an exception INSIDE them can never exit-7-mask the original error. */
|
||||
function guardRendererErrorHandlers(
|
||||
renderer: CliRenderer,
|
||||
preexisting: ReadonlyMap<ProcessErrorEvent, ReadonlySet<unknown>>
|
||||
): void {
|
||||
for (const event of PROCESS_ERROR_EVENTS) {
|
||||
const before = preexisting.get(event)
|
||||
for (const listener of proc.listeners(event)) {
|
||||
if (before?.has(listener)) continue
|
||||
proc.removeListener(event, listener)
|
||||
const guarded: ErrorListener = (...args) => {
|
||||
// After teardown the renderer can no longer report anything — rethrow so
|
||||
// the ORIGINAL error reaches Node's default fatal path unmasked.
|
||||
if (renderer.isDestroyed) throw args[0]
|
||||
try {
|
||||
listener(...args)
|
||||
} catch (handlerFailure) {
|
||||
try {
|
||||
const original = args[0]
|
||||
getLog().error('renderer', 'core error handler crashed while reporting an uncaught error', {
|
||||
original: original instanceof Error ? (original.stack ?? original.message) : String(original),
|
||||
handlerFailure: String(handlerFailure)
|
||||
})
|
||||
} catch {
|
||||
// logging is best-effort — never throw out of an exception handler.
|
||||
}
|
||||
}
|
||||
}
|
||||
proc.on(event, guarded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Runtime composition — the single edge where layers are provided and the
|
||||
* program is run (spec v4 §3.1). Layers are provided HERE by the caller
|
||||
* (the launcher entry), never inside components. Mirrors opencode
|
||||
* `cli/tui/layer.ts:6` + `cli/cmd/tui.ts` runMain.
|
||||
*/
|
||||
import { Layer } from 'effect'
|
||||
|
||||
import type { GatewayService } from './gateway/GatewayService.ts'
|
||||
|
||||
/**
|
||||
* The application layer. Phase 0 takes the GatewayService layer as a parameter
|
||||
* so the entry can choose Fake (dev/test) or — from Phase 1 — the live
|
||||
* `tui_gateway`-spawning layer. Compose additional boundary services
|
||||
* (Config, Theme-with-IO) here as they land.
|
||||
*/
|
||||
export const makeAppLayer = (gateway: Layer.Layer<GatewayService>) => Layer.mergeAll(gateway)
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* GatewayEvent — the wire event union, modeled as an Effect Schema and decoded
|
||||
* ONCE at the transport boundary (spec v4 §3.3). Mirrors Ink's
|
||||
* `ui-tui/src/gatewayTypes.ts:509-587` (discriminant = `type`).
|
||||
*
|
||||
* beta.78 API (verified vs .d.ts): variants are `Schema.Struct` with a
|
||||
* `Schema.Literal` `type`, combined with `Schema.Union([...]).pipe(
|
||||
* Schema.toTaggedUnion("type"))`. Optional fields use `Schema.optionalKey`
|
||||
* (exact-optional under exactOptionalPropertyTypes). Decode unknown wire JSON
|
||||
* with `Schema.decodeUnknownOption` so an UNRECOGNIZED `type` yields `Option.none`
|
||||
* and is skipped — a stray event never tears down the stream.
|
||||
*
|
||||
* Types are INFERRED from the schema (`typeof X["Type"]`), never hand-declared.
|
||||
*/
|
||||
import { Schema } from 'effect'
|
||||
|
||||
const Str = Schema.String
|
||||
const opt = Schema.optionalKey
|
||||
|
||||
// ── Skin (mirror GatewaySkin in ui-tui/src/gatewayTypes.ts) ───────────
|
||||
export const GatewaySkinSchema = Schema.Struct({
|
||||
banner_hero: opt(Str),
|
||||
banner_logo: opt(Str),
|
||||
branding: opt(Schema.Record(Str, Str)),
|
||||
colors: opt(Schema.Record(Str, Str)),
|
||||
help_header: opt(Str),
|
||||
tool_prefix: opt(Str),
|
||||
// Spinner animation data (faces/verbs/wings) — mixed array/tuple shapes, kept
|
||||
// loose at the boundary; the spinner component narrows what it reads. tool_emojis
|
||||
// is a per-tool glyph override map. Both additive + optional (back-compat).
|
||||
spinner: opt(Schema.Record(Str, Schema.Unknown)),
|
||||
tool_emojis: opt(Schema.Record(Str, Str))
|
||||
})
|
||||
export type GatewaySkinDecoded = typeof GatewaySkinSchema.Type
|
||||
|
||||
// ── Variant schemas (one per wire `type`) ─────────────────────────────
|
||||
// lifecycle
|
||||
const GatewayReady = Schema.Struct({
|
||||
type: Schema.Literal('gateway.ready'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ skin: opt(GatewaySkinSchema) }))
|
||||
})
|
||||
const SkinChanged = Schema.Struct({
|
||||
type: Schema.Literal('skin.changed'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(GatewaySkinSchema)
|
||||
})
|
||||
const SessionInfoEvent = Schema.Struct({
|
||||
type: Schema.Literal('session.info'),
|
||||
session_id: opt(Str),
|
||||
// SessionInfo is large + evolving; keep it loose at the boundary (Record),
|
||||
// the chrome phase narrows the fields it actually reads.
|
||||
payload: Schema.Record(Str, Schema.Unknown)
|
||||
})
|
||||
|
||||
// streaming text
|
||||
const MessageStart = Schema.Struct({ type: Schema.Literal('message.start'), session_id: opt(Str) })
|
||||
const MessageDelta = Schema.Struct({
|
||||
type: Schema.Literal('message.delta'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str) }))
|
||||
})
|
||||
const MessageComplete = Schema.Struct({
|
||||
type: Schema.Literal('message.complete'),
|
||||
session_id: opt(Str),
|
||||
// `usage` carries the post-turn token/context totals → refreshes the status bar
|
||||
// (item 14). Kept loose (Record) — the chrome reader narrows what it needs.
|
||||
payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str), usage: opt(Schema.Record(Str, Schema.Unknown)) }))
|
||||
})
|
||||
|
||||
// reasoning / thinking — toTaggedUnion needs ONE literal per member, so the
|
||||
// reasoning.delta/reasoning.available pair is two structs sharing a shape.
|
||||
const ReasoningShape = {
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ text: opt(Str), verbose: opt(Schema.Boolean) }))
|
||||
}
|
||||
const ReasoningDelta = Schema.Struct({ type: Schema.Literal('reasoning.delta'), ...ReasoningShape })
|
||||
const ReasoningAvailable = Schema.Struct({ type: Schema.Literal('reasoning.available'), ...ReasoningShape })
|
||||
const ThinkingDelta = Schema.Struct({
|
||||
type: Schema.Literal('thinking.delta'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ text: opt(Str) }))
|
||||
})
|
||||
|
||||
// tools
|
||||
const ToolStart = Schema.Struct({
|
||||
type: Schema.Literal('tool.start'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Record(Str, Schema.Unknown)
|
||||
})
|
||||
const ToolComplete = Schema.Struct({
|
||||
type: Schema.Literal('tool.complete'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Record(Str, Schema.Unknown)
|
||||
})
|
||||
const ToolProgress = Schema.Struct({
|
||||
type: Schema.Literal('tool.progress'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({ name: opt(Str), preview: opt(Str) })
|
||||
})
|
||||
const ToolGenerating = Schema.Struct({
|
||||
type: Schema.Literal('tool.generating'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({ name: opt(Str) })
|
||||
})
|
||||
|
||||
// blocking prompts (deadlock-critical — Phase 3 renders these)
|
||||
const ClarifyRequest = Schema.Struct({
|
||||
type: Schema.Literal('clarify.request'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({
|
||||
choices: opt(Schema.NullOr(Schema.Array(Str))),
|
||||
question: opt(Str),
|
||||
request_id: Str
|
||||
})
|
||||
})
|
||||
const ApprovalRequest = Schema.Struct({
|
||||
type: Schema.Literal('approval.request'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({ command: Str, description: Str })
|
||||
})
|
||||
const SudoRequest = Schema.Struct({
|
||||
type: Schema.Literal('sudo.request'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({ request_id: Str })
|
||||
})
|
||||
const SecretRequest = Schema.Struct({
|
||||
type: Schema.Literal('secret.request'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({ env_var: Str, prompt: Str, request_id: Str })
|
||||
})
|
||||
|
||||
// chrome / agent
|
||||
const StatusUpdate = Schema.Struct({
|
||||
type: Schema.Literal('status.update'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ kind: opt(Str), text: opt(Str) }))
|
||||
})
|
||||
const NotificationShow = Schema.Struct({
|
||||
type: Schema.Literal('notification.show'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Record(Str, Schema.Unknown)
|
||||
})
|
||||
const NotificationClear = Schema.Struct({
|
||||
type: Schema.Literal('notification.clear'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ key: opt(Str) }))
|
||||
})
|
||||
const VoiceStatus = Schema.Struct({
|
||||
type: Schema.Literal('voice.status'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ state: opt(Schema.Literals(['idle', 'listening', 'transcribing'])) }))
|
||||
})
|
||||
const VoiceTranscript = Schema.Struct({
|
||||
type: Schema.Literal('voice.transcript'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ no_speech_limit: opt(Schema.Boolean), text: opt(Str) }))
|
||||
})
|
||||
const BrowserProgress = Schema.Struct({
|
||||
type: Schema.Literal('browser.progress'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Record(Str, Schema.Unknown)
|
||||
})
|
||||
const BackgroundComplete = Schema.Struct({
|
||||
type: Schema.Literal('background.complete'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({ task_id: Str, text: Str })
|
||||
})
|
||||
const ReviewSummary = Schema.Struct({
|
||||
type: Schema.Literal('review.summary'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ text: opt(Str) }))
|
||||
})
|
||||
const SubagentShape = { session_id: opt(Str), payload: Schema.Record(Str, Schema.Unknown) }
|
||||
const SubagentSpawnRequested = Schema.Struct({ type: Schema.Literal('subagent.spawn_requested'), ...SubagentShape })
|
||||
const SubagentStart = Schema.Struct({ type: Schema.Literal('subagent.start'), ...SubagentShape })
|
||||
const SubagentThinking = Schema.Struct({ type: Schema.Literal('subagent.thinking'), ...SubagentShape })
|
||||
const SubagentTool = Schema.Struct({ type: Schema.Literal('subagent.tool'), ...SubagentShape })
|
||||
const SubagentProgress = Schema.Struct({ type: Schema.Literal('subagent.progress'), ...SubagentShape })
|
||||
const SubagentComplete = Schema.Struct({ type: Schema.Literal('subagent.complete'), ...SubagentShape })
|
||||
|
||||
// transport errors
|
||||
const ErrorEvent = Schema.Struct({
|
||||
type: Schema.Literal('error'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ message: opt(Str) }))
|
||||
})
|
||||
const GatewayStderr = Schema.Struct({
|
||||
type: Schema.Literal('gateway.stderr'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Struct({ line: Str })
|
||||
})
|
||||
const GatewayStartTimeout = Schema.Struct({
|
||||
type: Schema.Literal('gateway.start_timeout'),
|
||||
session_id: opt(Str),
|
||||
payload: Schema.Record(Str, Schema.Unknown)
|
||||
})
|
||||
const GatewayProtocolError = Schema.Struct({
|
||||
type: Schema.Literal('gateway.protocol_error'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ preview: opt(Str) }))
|
||||
})
|
||||
// gateway lifecycle recovery (auto-heal): the child exited (crash/kill) and the
|
||||
// transport is respawning+resuming the session. Surfaced so the frozen spinner
|
||||
// clears and the user sees the in-flight reply was lost (see store cases).
|
||||
const GatewayExited = Schema.Struct({
|
||||
type: Schema.Literal('gateway.exited'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ reason: opt(Str), code: opt(Schema.Number), signal: opt(Str) }))
|
||||
})
|
||||
const GatewayRecovering = Schema.Struct({
|
||||
type: Schema.Literal('gateway.recovering'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ attempt: opt(Schema.Number), delay_ms: opt(Schema.Number) }))
|
||||
})
|
||||
|
||||
// ── The union ─────────────────────────────────────────────────────────
|
||||
export const GatewayEventSchema = Schema.Union([
|
||||
GatewayReady,
|
||||
SkinChanged,
|
||||
SessionInfoEvent,
|
||||
MessageStart,
|
||||
MessageDelta,
|
||||
MessageComplete,
|
||||
ReasoningDelta,
|
||||
ReasoningAvailable,
|
||||
ThinkingDelta,
|
||||
ToolStart,
|
||||
ToolComplete,
|
||||
ToolProgress,
|
||||
ToolGenerating,
|
||||
ClarifyRequest,
|
||||
ApprovalRequest,
|
||||
SudoRequest,
|
||||
SecretRequest,
|
||||
StatusUpdate,
|
||||
NotificationShow,
|
||||
NotificationClear,
|
||||
VoiceStatus,
|
||||
VoiceTranscript,
|
||||
BrowserProgress,
|
||||
BackgroundComplete,
|
||||
ReviewSummary,
|
||||
SubagentSpawnRequested,
|
||||
SubagentStart,
|
||||
SubagentThinking,
|
||||
SubagentTool,
|
||||
SubagentProgress,
|
||||
SubagentComplete,
|
||||
ErrorEvent,
|
||||
GatewayStderr,
|
||||
GatewayStartTimeout,
|
||||
GatewayProtocolError,
|
||||
GatewayExited,
|
||||
GatewayRecovering
|
||||
]).pipe(Schema.toTaggedUnion('type'))
|
||||
|
||||
/** The decoded, typed event. Inferred from the schema — never hand-declared. */
|
||||
export type GatewayEvent = typeof GatewayEventSchema.Type
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* SessionInfo + Catalog decoders — the decode-at-boundary idiom (spec v4 §3.3),
|
||||
* mirroring GatewayEvent.ts. These two payloads are UNTRUSTED loose JSON from the
|
||||
* Python `tui_gateway` (`session.info` event / `session.create`/`resume` result
|
||||
* `info`, and the `startup.catalog` RPC result), so they are decoded ONCE with an
|
||||
* Effect Schema instead of hand-rolled `as`-cast readers.
|
||||
*
|
||||
* Decode with `Schema.decodeUnknownOption`: a malformed/partial payload yields
|
||||
* `Option.none` and the caller falls back to an empty patch / leaves the catalog
|
||||
* unset — a stray shape never crashes the reducer.
|
||||
*
|
||||
* Wire field names are verified against `tui_gateway/server.py`:
|
||||
* - session.info → `_session_info()` (server.py:~1798): top-level `model`,
|
||||
* `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, `profile_name`,
|
||||
* `update_behind` (Optional[int] — null until the prefetched check lands),
|
||||
* `update_command`, `mcp_servers` (list of {name,transport,connected,tools}
|
||||
* dicts from `get_mcp_status()`), plus a nested `usage` (`_get_usage()`,
|
||||
* server.py:~1683) carrying `context_used`, `context_max`,
|
||||
* `context_percent`, `compressions` (context_* only present when the
|
||||
* compressor knows a context length) and `cost_usd` (only when the pricing
|
||||
* estimate succeeds).
|
||||
* - startup.catalog → `@method("startup.catalog")` (server.py:~8521):
|
||||
* `{ tools:{total, toolsets:[{name,count,enabled,tools}]},
|
||||
* skills:{total, categories:[{name,count}]}, mcp:{servers:[]} }`.
|
||||
*
|
||||
* These schemas are used PURELY as decoders; they do NOT Effect-ify the store's
|
||||
* reactivity or control flow (Solid stays the runtime — spec v4 §1).
|
||||
*/
|
||||
import { Schema } from 'effect'
|
||||
|
||||
const Str = Schema.String
|
||||
const Num = Schema.Number
|
||||
const Bool = Schema.Boolean
|
||||
const opt = Schema.optionalKey
|
||||
|
||||
// ── session.info / session.create.info ────────────────────────────────
|
||||
// Context/usage numbers arrive nested under `usage`; the same names may also
|
||||
// appear at the top level depending on the RPC vs event path (the reader prefers
|
||||
// `usage.context_*`, then the top-level fallback). All keys are optional — a
|
||||
// `session.info` patch only carries the fields that actually changed.
|
||||
const UsageSchema = Schema.Struct({
|
||||
context_used: opt(Num),
|
||||
context_max: opt(Num),
|
||||
context_percent: opt(Num),
|
||||
compressions: opt(Num),
|
||||
cost_usd: opt(Num)
|
||||
})
|
||||
|
||||
export const SessionInfoPatchSchema = Schema.Struct({
|
||||
model: opt(Str),
|
||||
reasoning_effort: opt(Str),
|
||||
fast: opt(Bool),
|
||||
cwd: opt(Str),
|
||||
branch: opt(Str),
|
||||
// session title ("" until the first exchange titles it) — drives the
|
||||
// terminal window-title chrome (OSC 0/2 via renderer.setTerminalTitle).
|
||||
title: opt(Str),
|
||||
running: opt(Bool),
|
||||
// status-bar chrome extras (Epic 1.3): update banner, profile badge, MCP count.
|
||||
// `update_behind` is null on the wire until the async update check resolves.
|
||||
update_behind: opt(Schema.NullOr(Num)),
|
||||
update_command: opt(Str),
|
||||
profile_name: opt(Str),
|
||||
mcp_servers: opt(Schema.Array(Schema.Unknown)),
|
||||
// top-level context fallback (used when there's no nested `usage`)
|
||||
context_used: opt(Num),
|
||||
context_max: opt(Num),
|
||||
context_percent: opt(Num),
|
||||
compressions: opt(Num),
|
||||
usage: opt(UsageSchema)
|
||||
})
|
||||
export type SessionInfoPatchDecoded = typeof SessionInfoPatchSchema.Type
|
||||
|
||||
/** Decode a loose session.info payload → `Option<SessionInfoPatchDecoded>`. */
|
||||
export const decodeSessionInfoPatch = Schema.decodeUnknownOption(SessionInfoPatchSchema)
|
||||
|
||||
// ── startup.catalog ───────────────────────────────────────────────────
|
||||
// Mirrors the `Catalog` interface in store.ts. `enabled` defaults to true at the
|
||||
// reader (an absent flag means on), so it stays optional here.
|
||||
const ToolsetSchema = Schema.Struct({
|
||||
name: opt(Str),
|
||||
count: opt(Num),
|
||||
enabled: opt(Bool),
|
||||
tools: opt(Schema.Array(Schema.Unknown))
|
||||
})
|
||||
const CategorySchema = Schema.Struct({
|
||||
name: opt(Str),
|
||||
count: opt(Num)
|
||||
})
|
||||
|
||||
export const CatalogSchema = Schema.Struct({
|
||||
tools: opt(
|
||||
Schema.Struct({
|
||||
total: opt(Num),
|
||||
toolsets: opt(Schema.Array(ToolsetSchema))
|
||||
})
|
||||
),
|
||||
skills: opt(
|
||||
Schema.Struct({
|
||||
total: opt(Num),
|
||||
categories: opt(Schema.Array(CategorySchema))
|
||||
})
|
||||
),
|
||||
mcp: opt(
|
||||
Schema.Struct({
|
||||
servers: opt(Schema.Array(Schema.Unknown))
|
||||
})
|
||||
)
|
||||
})
|
||||
export type CatalogDecoded = typeof CatalogSchema.Type
|
||||
|
||||
/** Decode a loose startup.catalog result → `Option<CatalogDecoded>`. */
|
||||
export const decodeCatalog = Schema.decodeUnknownOption(CatalogSchema)
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* SessionPeek decoder — decode-at-boundary (house rule) for the `session.peek`
|
||||
* RPC result (tui_gateway/server.py `@method("session.peek")`, shipped with the
|
||||
* resume-picker gateway half, commit 529d8084b). The response powers the
|
||||
* picker's Space preview:
|
||||
*
|
||||
* { session: {id, title, source, model, cwd, started_at, ended_at,
|
||||
* end_reason, message_count, last_active, cost_usd},
|
||||
* head: [{id, role, content(≤2000), truncated, timestamp}, …],
|
||||
* tail: [same — never overlaps head],
|
||||
* total_messages: int }
|
||||
*
|
||||
* Wire nullability per the server: `model`/`cwd`/`ended_at`/`end_reason`/
|
||||
* `cost_usd` are `None` when unknown; message `id`/`timestamp` come straight
|
||||
* off DB rows (left loose). Decoded with `Schema.decodeUnknownOption` — a
|
||||
* malformed payload yields `Option.none` and the preview pane shows its
|
||||
* honest "preview unavailable" line instead of crashing the overlay.
|
||||
*/
|
||||
import { Schema } from 'effect'
|
||||
|
||||
const Str = Schema.String
|
||||
const Num = Schema.Number
|
||||
const opt = Schema.optionalKey
|
||||
|
||||
const PeekMessageSchema = Schema.Struct({
|
||||
role: opt(Str),
|
||||
content: opt(Str),
|
||||
truncated: opt(Schema.Boolean),
|
||||
timestamp: opt(Schema.NullOr(Schema.Unknown))
|
||||
})
|
||||
|
||||
export const SessionPeekSchema = Schema.Struct({
|
||||
session: opt(
|
||||
Schema.Struct({
|
||||
id: opt(Str),
|
||||
title: opt(Schema.NullOr(Str)),
|
||||
source: opt(Schema.NullOr(Str)),
|
||||
model: opt(Schema.NullOr(Str)),
|
||||
cwd: opt(Schema.NullOr(Str)),
|
||||
started_at: opt(Schema.NullOr(Num)),
|
||||
ended_at: opt(Schema.NullOr(Num)),
|
||||
end_reason: opt(Schema.NullOr(Str)),
|
||||
message_count: opt(Schema.NullOr(Num)),
|
||||
last_active: opt(Schema.NullOr(Num)),
|
||||
cost_usd: opt(Schema.NullOr(Num))
|
||||
})
|
||||
),
|
||||
head: opt(Schema.Array(PeekMessageSchema)),
|
||||
tail: opt(Schema.Array(PeekMessageSchema)),
|
||||
total_messages: opt(Num)
|
||||
})
|
||||
export type SessionPeekDecoded = typeof SessionPeekSchema.Type
|
||||
|
||||
/** Decode a loose session.peek result → `Option<SessionPeekDecoded>`. */
|
||||
export const decodeSessionPeek = Schema.decodeUnknownOption(SessionPeekSchema)
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Terminal chrome seam — window title (OSC 0/2) + desktop notifications
|
||||
* through the renderer's native primitives.
|
||||
*
|
||||
* Why the renderer and not process.stdout: the zig side owns the terminal —
|
||||
* `setTerminalTitle` and `triggerNotification` are native FFI calls and
|
||||
* `writeOut` serializes raw control bytes with frame presentation, so chrome
|
||||
* writes can never tear a frame.
|
||||
*
|
||||
* Notifications go through the native `renderer.triggerNotification(message,
|
||||
* title)` (zig `lib.triggerNotification`), NOT a hand-rolled OSC 9/99/777 spray.
|
||||
* The zig side does what raw OSC can't: authoritative protocol detection
|
||||
* (query > heuristic) so it picks the ONE protocol the terminal speaks, **tmux
|
||||
* DCS passthrough wrapping** (raw OSC is silently eaten by tmux), and Zellij
|
||||
* OSC-99 enforcement. It returns `false` when no protocol was detected.
|
||||
*
|
||||
* Focus suppression: core parses mode-1004 focus reports (`ESC[I`/`ESC[O`)
|
||||
* and re-emits them as renderer `focus`/`blur` events — notifications are
|
||||
* skipped while the terminal reports focused (you're already looking at it).
|
||||
* Native `triggerNotification` does NOT do focus suppression, so it stays our
|
||||
* policy here. Terminals that never report focus leave the state at the
|
||||
* assumed-focused initial value… which would swallow every notification, so
|
||||
* the FIRST blur is what arms suppression: until a blur arrives we treat focus
|
||||
* as unknown and notify unconditionally (worst case: a redundant ping while
|
||||
* focused).
|
||||
*
|
||||
* Everything here is total — chrome must never throw into the render loop
|
||||
* or a teardown path.
|
||||
*/
|
||||
import type { CliRenderer } from '@opentui/core'
|
||||
|
||||
import type { TermNotification } from '../logic/termChrome.ts'
|
||||
import {
|
||||
notifyEnabled,
|
||||
sanitizeOscText,
|
||||
TITLE_STACK_RESTORE,
|
||||
TITLE_STACK_SAVE,
|
||||
windowTitleFor
|
||||
} from '../logic/termChrome.ts'
|
||||
import { getLog } from './log.ts'
|
||||
|
||||
/** What the view layer needs from the chrome seam (DI-friendly for tests). */
|
||||
export interface TerminalChromeSeam {
|
||||
/** Set the window title from the session title (undefined → generic). */
|
||||
readonly setTitle: (sessionTitle: string | undefined) => void
|
||||
/** Announce "waiting on you" to the hosting terminal (no-op while focused). */
|
||||
readonly notify: (notification: TermNotification) => void
|
||||
}
|
||||
|
||||
/** The renderer surface the seam writes through (runtime-verified shapes). */
|
||||
interface RendererSeam {
|
||||
setTerminalTitle(title: string): void
|
||||
/** Native desktop notification (protocol detection + tmux/Zellij wrapping). */
|
||||
triggerNotification(message: string, title?: string): boolean
|
||||
writeOut(chunk: string): void
|
||||
on(event: 'focus' | 'blur', listener: () => void): unknown
|
||||
once(event: 'destroy', listener: () => void): unknown
|
||||
readonly isDestroyed: boolean
|
||||
}
|
||||
|
||||
/** Install the chrome seam on a live renderer. Idempotent per renderer use —
|
||||
* the entry calls it once, right next to the render bridge. */
|
||||
export function installTerminalChrome(renderer: CliRenderer): TerminalChromeSeam {
|
||||
const seam = renderer as unknown as RendererSeam
|
||||
const notificationsOn = notifyEnabled()
|
||||
|
||||
// unknown (null) until the terminal proves it reports focus; then boolean.
|
||||
let focused: boolean | null = null
|
||||
try {
|
||||
seam.on('focus', () => {
|
||||
focused = true
|
||||
})
|
||||
seam.on('blur', () => {
|
||||
focused = false
|
||||
})
|
||||
} catch (cause) {
|
||||
getLog().warn('chrome', 'focus tracking unavailable', { cause: String(cause) })
|
||||
}
|
||||
|
||||
// Bracket our title ownership: save the user's title now, restore on quit.
|
||||
// Best-effort — terminals without the XTWINOPS title stack ignore both.
|
||||
writeRaw(seam, TITLE_STACK_SAVE)
|
||||
seam.once('destroy', () => writeRaw(seam, TITLE_STACK_RESTORE, { evenIfDestroyed: true }))
|
||||
|
||||
let lastTitle = ''
|
||||
return {
|
||||
setTitle: sessionTitle => {
|
||||
const title = windowTitleFor(sessionTitle)
|
||||
if (title === lastTitle) return
|
||||
lastTitle = title
|
||||
try {
|
||||
if (!seam.isDestroyed) seam.setTerminalTitle(title)
|
||||
} catch (cause) {
|
||||
getLog().warn('chrome', 'setTerminalTitle failed', { cause: String(cause) })
|
||||
}
|
||||
},
|
||||
notify: notification => {
|
||||
if (!notificationsOn || focused === true) return
|
||||
// Map our {title:'Hermes', body:'finished — …'} → native (message, title):
|
||||
// native API takes the BODY as the message and the heading as the title.
|
||||
const title = sanitizeOscText(notification.title)
|
||||
const body = sanitizeOscText(notification.body ?? '')
|
||||
if (!title) return
|
||||
const message = body || title
|
||||
try {
|
||||
if (!seam.isDestroyed) seam.triggerNotification(message, title)
|
||||
} catch (cause) {
|
||||
getLog().warn('chrome', 'triggerNotification failed', { cause: String(cause) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw control write through the renderer; falls back to process.stdout when
|
||||
* the renderer is already gone (the title-stack restore on destroy — at that
|
||||
* point there is no frame left to tear). */
|
||||
function writeRaw(seam: RendererSeam, chunk: string, options?: { evenIfDestroyed?: boolean }): void {
|
||||
try {
|
||||
if (!seam.isDestroyed) {
|
||||
seam.writeOut(chunk)
|
||||
return
|
||||
}
|
||||
if (options?.evenIfDestroyed) process.stdout.write(chunk)
|
||||
} catch (cause) {
|
||||
getLog().warn('chrome', 'control write failed', { cause: String(cause) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* FakeGateway — the test/dev implementation of GatewayService (spec v4 §2/§5
|
||||
* Layer-3 seam). Provides an emittable event source and a spy `request`, so
|
||||
* store/component tests can drive synthetic streams and assert RPC calls
|
||||
* without spawning Python. Mirrors opencode's injectable fake transport.
|
||||
*
|
||||
* Phase 0 uses it to stream a scripted "hello" so the entry/test renders a
|
||||
* non-empty frame. Phase 1 swaps in `liveGateway.layer` (real `tui_gateway`).
|
||||
*/
|
||||
import { Effect, Layer } from 'effect'
|
||||
|
||||
import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts'
|
||||
import type { GatewayEvent } from '../boundary/schema/GatewayEvent.ts'
|
||||
|
||||
export interface FakeGatewayController {
|
||||
readonly service: GatewayServiceShape
|
||||
/** Emit a decoded event to all subscribers (drives the store in tests). */
|
||||
readonly emit: (event: GatewayEvent) => void
|
||||
/** Recorded (method, params) pairs from `request` calls. */
|
||||
readonly calls: Array<{ method: string; params: unknown }>
|
||||
}
|
||||
|
||||
/** Build a fresh fake controller (used directly in tests, or wrapped as a Layer). */
|
||||
export function makeFakeGateway(initialSessionId = 'fake-session'): FakeGatewayController {
|
||||
const handlers = new Set<(event: GatewayEvent) => void>()
|
||||
const calls: Array<{ method: string; params: unknown }> = []
|
||||
|
||||
const service: GatewayServiceShape = {
|
||||
subscribe: handler =>
|
||||
Effect.sync(() => {
|
||||
handlers.add(handler)
|
||||
return () => {
|
||||
handlers.delete(handler)
|
||||
}
|
||||
}),
|
||||
request: <A>(method: string, params: unknown) =>
|
||||
Effect.sync(() => {
|
||||
calls.push({ method, params })
|
||||
return undefined as A
|
||||
}),
|
||||
sessionId: () => initialSessionId
|
||||
}
|
||||
|
||||
return {
|
||||
service,
|
||||
emit: event => {
|
||||
for (const handler of handlers) handler(event)
|
||||
},
|
||||
calls
|
||||
}
|
||||
}
|
||||
|
||||
/** A GatewayService layer backed by a fresh FakeGateway. The controller is
|
||||
* reachable for assertions via the returned tuple in tests; for the dev entry
|
||||
* use {@link fakeGatewayLayer} and drive it from a scripted effect. */
|
||||
export function fakeGatewayLayerWith(controller: FakeGatewayController): Layer.Layer<GatewayService> {
|
||||
return Layer.succeed(GatewayService, controller.service)
|
||||
}
|
||||
|
||||
/** Convenience: a layer + its controller, for the dev entry's scripted stream. */
|
||||
export function makeFakeGatewayLayer(): { layer: Layer.Layer<GatewayService>; controller: FakeGatewayController } {
|
||||
const controller = makeFakeGateway()
|
||||
return { layer: Layer.succeed(GatewayService, controller.service), controller }
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* Entry — the single boundary edge (spec v4 §3.1). This is the ONE place that:
|
||||
* - acquires the renderer (acquireRelease + Deferred-on-destroy),
|
||||
* - creates the Solid store,
|
||||
* - wires GatewayService.subscribe -> store.apply (Effect->Solid contact #2),
|
||||
* - does the one-line `render(() => <App/>, renderer)` bridge (contact #1),
|
||||
* - (live) bootstraps a session and optionally submits an initial prompt,
|
||||
* - blocks until the renderer is destroyed (user quit),
|
||||
* and at the bottom PROVIDES the layers and runs (`Effect.provide(AppLayer)`).
|
||||
*
|
||||
* Backend selection (import.meta.main):
|
||||
* - default → the LIVE `liveGatewayLayer` (spawns the real Python
|
||||
* `tui_gateway`); after `gateway.ready` it `session.create`s and, if an
|
||||
* initial prompt is given (HERMES_TUI_PROMPT or argv), `prompt.submit`s it.
|
||||
* The composer lands in Phase 2 — until then the initial prompt is how a
|
||||
* streamed reply is driven into the transcript (spec Phase-1 smoke).
|
||||
* - HERMES_TUI_FAKE=1 → the scripted FakeGateway "hello" (offline dev/CI).
|
||||
*
|
||||
* The body of `run` does not change when the backend swaps — that's the point of
|
||||
* the layer; only `makeAppLayer(...)` differs at the edge.
|
||||
*/
|
||||
import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui'
|
||||
import { KeymapProvider } from '@opentui/keymap/solid'
|
||||
import { render } from '@opentui/solid'
|
||||
import { Deferred, Duration, Effect } from 'effect'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
import { readClipboardImage, writeClipboard } from '../boundary/clipboard.ts'
|
||||
import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts'
|
||||
import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
||||
import { getLog } from '../boundary/log.ts'
|
||||
import { startMemlog } from '../boundary/memlog.ts'
|
||||
import { startMemoryMonitor } from '../boundary/memoryMonitor.ts'
|
||||
import { startProactiveGc } from '../boundary/proactiveGc.ts'
|
||||
import { registerRemoteParsers } from '../boundary/parsers.ts'
|
||||
import { acquireRenderer } from '../boundary/renderer.ts'
|
||||
import { makeAppLayer } from '../boundary/runtime.ts'
|
||||
import { nthAssistantResponse } from '../logic/copy.ts'
|
||||
import { performHeapdump } from '../logic/diagnostics.ts'
|
||||
import {
|
||||
envFlag,
|
||||
heapdumpOnStart,
|
||||
launchCwd,
|
||||
noConfirmDestructive,
|
||||
resolveMouseEnabled,
|
||||
startupImage,
|
||||
startupPrompt,
|
||||
STARTUP_IMAGE_DEFAULT_PROMPT
|
||||
} from '../logic/env.ts'
|
||||
import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
|
||||
import { parseProcessList } from '../logic/backgroundActivity.ts'
|
||||
import { createPasteStore } from '../logic/pastes.ts'
|
||||
import { mapResumeHistory } from '../logic/resume.ts'
|
||||
import {
|
||||
classifySubmit,
|
||||
catalogCommandItems,
|
||||
createCompletionGate,
|
||||
dispatchSlash,
|
||||
mapCompletions,
|
||||
mapModelOptions,
|
||||
planCompletion,
|
||||
readReplaceFrom,
|
||||
registerModelPrefetch,
|
||||
type SlashContext
|
||||
} from '../logic/slash.ts'
|
||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { seedLearnedNames } from '../view/composer.tsx'
|
||||
import { TerminalChrome } from '../view/terminalChrome.tsx'
|
||||
|
||||
// Syntax-highlighting language expansion: register the remote tree-sitter
|
||||
// grammars (python/rust/go/bash/json/c/html/css/yaml/toml) before the first
|
||||
// <code>/<markdown> mount initializes the global tree-sitter client. Grammars
|
||||
// are fetched from GitHub on first use and cached under HERMES_TUI_PARSER_CACHE.
|
||||
registerRemoteParsers()
|
||||
import type { SessionPickerOps } from '../view/overlays/sessionPicker.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { makeFakeGatewayLayer, type FakeGatewayController } from './fakeGateway.ts'
|
||||
|
||||
export interface TuiInput {
|
||||
/** Mouse tracking on/off. */
|
||||
readonly mouse: boolean
|
||||
/** Skip the live session bootstrap (the fake backend drives the stream itself). */
|
||||
readonly fake: boolean
|
||||
/** Terminal width passed to `session.create` (Ink uses the live cols; 80 is a fine default). */
|
||||
readonly cols: number
|
||||
/** Optional initial prompt submitted once the session is ready — the Phase-1 stand-in for the composer. */
|
||||
readonly initialPrompt?: string
|
||||
/** Optional image PATH attached (image.attach) before the initial prompt — `hermes --tui --image <path>`. */
|
||||
readonly initialImage?: string
|
||||
/** Resume a session instead of creating one: a session id, 'recent'/'last'
|
||||
* (→ session.most_recent), or 'picker' (bare `--resume` — open the resume
|
||||
* picker BEFORE any session.create; create stays lazy). */
|
||||
readonly resumeId?: string
|
||||
}
|
||||
|
||||
const READY_POLL = Duration.millis(100)
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
/** Window after a Ctrl+C in which a second Ctrl+C quits the TUI (item 11). */
|
||||
const QUIT_WINDOW_MS = 3_000
|
||||
|
||||
/** Recursive renderable count under a node (the /mem store-cap diagnostic —
|
||||
* same walk as scripts/mem-bench.tsx; cheap: one tree pass on demand). */
|
||||
function descendantCount(node: { getChildren(): unknown[] }): number {
|
||||
let n = 0
|
||||
for (const child of node.getChildren()) {
|
||||
n += 1
|
||||
if (child && typeof child === 'object' && 'getChildren' in child) {
|
||||
n += descendantCount(child as { getChildren(): unknown[] })
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a session INTO the store: buffer live events across the `session.resume`
|
||||
* RPC, then replace history + replay (gotcha §8 #5 tool rows handled by
|
||||
* mapResumeHistory). Shared by the launch bootstrap and the session switcher.
|
||||
* Timed (rpc_ms / hydrate_ms) for the resume profile.
|
||||
*/
|
||||
/**
|
||||
* Record the CURRENT session id in `HERMES_TUI_ACTIVE_SESSION_FILE` (item #5).
|
||||
* The launcher reads this on exit to print the right "Resume this session with…"
|
||||
* epilogue (hermes_cli/main.py `_print_tui_exit_summary`). The Ink TUI writes it on
|
||||
* every session change (useSessionLifecycle.writeActiveSessionFile); the native
|
||||
* engine must too, or the launcher falls back to the INITIAL launch session and
|
||||
* shows resume info for the wrong session after a `/session` switch.
|
||||
*/
|
||||
const writeActiveSession = (sid: string | undefined) => {
|
||||
const file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE
|
||||
if (!file || !sid) return
|
||||
try {
|
||||
writeFileSync(file, JSON.stringify({ session_id: sid }), { mode: 0o600 })
|
||||
} catch (cause) {
|
||||
getLog().warn('bootstrap', 'active-session-file write failed', { cause: String(cause) })
|
||||
}
|
||||
}
|
||||
|
||||
const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: string, cols: number) =>
|
||||
Effect.gen(function* () {
|
||||
writeActiveSession(sid) // the session we're switching to is now the active one (#5)
|
||||
store.setSessionId(sid)
|
||||
store.beginBuffer()
|
||||
const t0 = Date.now()
|
||||
const resumed = yield* gateway.request<{ messages?: unknown; info?: Record<string, unknown> }>('session.resume', {
|
||||
cols,
|
||||
session_id: sid,
|
||||
// native engine renders tools collapsed → safe to fold each tool's capped
|
||||
// result into the resume snapshot so resumed turns render like live (item 1).
|
||||
with_tool_output: true
|
||||
})
|
||||
const t1 = Date.now()
|
||||
const snapshot = mapResumeHistory(resumed?.messages)
|
||||
store.commitSnapshot(snapshot)
|
||||
if (resumed?.info) store.applyInfo(resumed.info)
|
||||
getLog().info('bootstrap', 'session resumed', {
|
||||
count: snapshot.length,
|
||||
hydrate_ms: Date.now() - t1,
|
||||
rpc_ms: t1 - t0,
|
||||
sid
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Post-session setup, shared by every way a session comes to exist (create,
|
||||
* boot resume, boot-picker pick): the tools/skills/MCP catalog for the home
|
||||
* panel (item 9 — best-effort), the optional initial prompt, and the `/model`
|
||||
* catalog prefetch (Epic 7 instant open: `model.options` is the slow RPC —
|
||||
* network pricing fetch + Nous tier check — so pay it ONCE in an already-
|
||||
* forked fiber; the promise is STASHED in the slash seam so an early `/model`
|
||||
* awaits THIS request instead of doubling it).
|
||||
*/
|
||||
const postSessionSetup = (
|
||||
gateway: GatewayServiceShape,
|
||||
store: SessionStore,
|
||||
sid: string,
|
||||
initialPrompt?: string,
|
||||
initialImage?: string
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* gateway
|
||||
.request<unknown>('startup.catalog', { session_id: sid })
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
if (catalog) store.setCatalog(catalog)
|
||||
|
||||
// Seed the composer's slash-highlight catalog ONCE at boot (glitch
|
||||
// 2026-06-14): `commands.catalog` returns the full uncapped command + skill
|
||||
// name list ({pairs:[["/name","desc"],…]}); feeding the names through
|
||||
// seedLearnedNames means a cold `/command` highlights on the first keystroke
|
||||
// instead of only after its completion batch was browsed earlier. Best-effort
|
||||
// — a failure just leaves the old lazy-learn behavior.
|
||||
const cmdCatalog = yield* gateway
|
||||
.request<unknown>('commands.catalog', {})
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
seedLearnedNames(catalogCommandItems(cmdCatalog))
|
||||
|
||||
// Seeded image (`hermes --tui --image <path>`): attach BEFORE submitting, so
|
||||
// the next prompt.submit picks it up — exact Ink parity (createGatewayEventHandler
|
||||
// scheduleStartupPrompt: image.attach then submit; default prompt when image-only).
|
||||
const image = initialImage?.trim()
|
||||
if (image) {
|
||||
yield* gateway.request('image.attach', { path: image, session_id: sid }).pipe(
|
||||
Effect.catchCause(cause =>
|
||||
Effect.sync(() => {
|
||||
getLog().warn('bootstrap', 'startup image attach failed', { cause: String(cause) })
|
||||
store.pushSystem(`startup image attach failed: ${String(cause)}`)
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const prompt = initialPrompt?.trim() || (image ? STARTUP_IMAGE_DEFAULT_PROMPT : undefined)
|
||||
if (prompt) {
|
||||
store.pushUser(prompt)
|
||||
yield* gateway.request('prompt.submit', { session_id: sid, text: prompt })
|
||||
}
|
||||
|
||||
const prefetch = Effect.runPromise(
|
||||
gateway
|
||||
.request<unknown>('model.options', { session_id: sid })
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
).then(modelOpts => {
|
||||
const modelItems = mapModelOptions(modelOpts)
|
||||
if (modelItems.length) store.setModelItems(modelItems)
|
||||
})
|
||||
registerModelPrefetch(prefetch)
|
||||
yield* Effect.promise(() => prefetch)
|
||||
})
|
||||
|
||||
/** Create a FRESH session + run the post-session setup (the default boot path;
|
||||
* also the boot-picker's Esc fallback — closing the picker without a pick
|
||||
* must still leave a usable session behind). */
|
||||
const createFreshSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) =>
|
||||
Effect.gen(function* () {
|
||||
const created = yield* gateway.request<{ session_id?: string; info?: Record<string, unknown> }>('session.create', {
|
||||
cols: input.cols,
|
||||
// The launch directory IS the workspace choice in a terminal (you cd'd
|
||||
// here) — passing it makes the gateway treat it as explicit, so the
|
||||
// session row gets a persisted cwd on first message, the chrome bar shows
|
||||
// the right dir, and /sessions groups this directory's sessions first.
|
||||
// NOT process.cwd(): the hermes launcher runs this engine with cwd set to
|
||||
// its own package dir (ui-opentui), so process.cwd() would be the engine
|
||||
// dir. The launcher exports the REAL launch dir as HERMES_CWD / the
|
||||
// gateway's TERMINAL_CWD; prefer those, falling back to process.cwd()
|
||||
// only when launched standalone (smokes/dev). (Desktop omits cwd — its
|
||||
// launch dir is meaningless; see _ensure_session_db_row.)
|
||||
cwd: launchCwd()
|
||||
})
|
||||
const sid = created?.session_id ?? gateway.sessionId()
|
||||
if (!sid) {
|
||||
getLog().warn('bootstrap', 'session.create returned no session_id')
|
||||
return
|
||||
}
|
||||
if (created?.info) store.applyInfo(created.info)
|
||||
writeActiveSession(sid) // record the new session for the launcher's exit epilogue (#5)
|
||||
store.setSessionId(sid)
|
||||
getLog().info('bootstrap', 'session created', { sid })
|
||||
yield* postSessionSetup(gateway, store, sid, input.initialPrompt, input.initialImage)
|
||||
})
|
||||
|
||||
/**
|
||||
* Live session bootstrap: wait for the unsolicited `gateway.ready` handshake,
|
||||
* then either RESUME a session (hydrate its transcript — incl. tool rows — via
|
||||
* the snapshot, buffering live events across the RPC), open the resume PICKER
|
||||
* (`resumeId === 'picker'` — bare `--resume`: no session is created until the
|
||||
* user picks or closes; create is lazy), or CREATE a fresh one, and (if given)
|
||||
* submit the initial prompt. Forked into the entry scope so it runs
|
||||
* concurrently with the render + the quit-await. Any failure is logged and
|
||||
* swallowed — a bootstrap hiccup must never tear down the rendered UI.
|
||||
*/
|
||||
const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) =>
|
||||
Effect.gen(function* () {
|
||||
const log = getLog()
|
||||
let waited = 0
|
||||
while (!store.state.ready && waited < READY_TIMEOUT_MS) {
|
||||
yield* Effect.sleep(READY_POLL)
|
||||
waited += 100
|
||||
}
|
||||
if (!store.state.ready) {
|
||||
log.warn('bootstrap', 'no gateway.ready within timeout', { waited })
|
||||
return
|
||||
}
|
||||
|
||||
if (input.resumeId === 'picker') {
|
||||
// Boot picker (design doc §A): opens BEFORE any session.create. The pick
|
||||
// resumes via onResume (which then runs postSessionSetup); a close
|
||||
// without a pick falls back to createFreshSession (onSessionPickerClosed).
|
||||
store.openSessionPicker('recent')
|
||||
return
|
||||
}
|
||||
|
||||
if (input.resumeId) {
|
||||
let sid: string | undefined = input.resumeId
|
||||
if (sid === 'recent' || sid === 'last') {
|
||||
const recent = yield* gateway.request<{ session_id?: string }>('session.most_recent', {})
|
||||
sid = recent?.session_id
|
||||
}
|
||||
if (!sid) {
|
||||
log.warn('bootstrap', 'no session to resume', { resumeId: input.resumeId })
|
||||
return
|
||||
}
|
||||
yield* resumeInto(gateway, store, sid, input.cols)
|
||||
yield* postSessionSetup(gateway, store, sid, input.initialPrompt, input.initialImage)
|
||||
return
|
||||
}
|
||||
|
||||
yield* createFreshSession(gateway, store, input)
|
||||
}).pipe(Effect.catchCause(cause => Effect.sync(() => getLog().warn('bootstrap', 'failed', { cause: String(cause) }))))
|
||||
|
||||
/** The entry Effect. Mirrors opencode `app.tsx:177` `run = Effect.fn("Tui.run")`. */
|
||||
export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
// Solid side: the store + reducer. Created here, lives in Solid-land.
|
||||
const store = createSessionStore()
|
||||
|
||||
// Prompt history (item 6): scoped to the launch directory so prior prompts
|
||||
// from the same project dir are recallable (Up/Down), without bleeding
|
||||
// across different dirs. process.cwd() is the user's launch dir under the
|
||||
// real launcher.
|
||||
const historyCwd = process.cwd()
|
||||
const history = createPromptHistory({
|
||||
initial: loadDirHistory(historyCwd),
|
||||
persist: dirHistoryPersister(historyCwd)
|
||||
})
|
||||
|
||||
// Pasted-text store — created ONCE here so it survives the composer
|
||||
// remounting (overlay open/close); a per-composer store would lose a
|
||||
// pending `[Pasted text #N]` mid-compose and submit would send it literally.
|
||||
const pasteStore = createPasteStore()
|
||||
|
||||
// Contact point #2: boundary pushes decoded events into the Solid store.
|
||||
// The callback ALSO drives auto-heal re-resume: a post-crash gateway.ready
|
||||
// (i.e. one that follows a gateway.exited, so `recoverSid` is set) re-resumes
|
||||
// the session so the transcript continues. The INITIAL gateway.ready has
|
||||
// `recoverSid === undefined`, so the normal bootstrap path is untouched.
|
||||
const gateway = yield* GatewayService
|
||||
let recoverSid: string | undefined
|
||||
yield* gateway.subscribe(event => {
|
||||
store.apply(event)
|
||||
if (event.type === 'gateway.exited') {
|
||||
recoverSid = gateway.sessionId() ?? recoverSid
|
||||
} else if (event.type === 'gateway.ready' && recoverSid !== undefined) {
|
||||
const sid = recoverSid
|
||||
recoverSid = undefined
|
||||
Effect.runFork(
|
||||
resumeInto(gateway, store, sid, input.cols).pipe(
|
||||
Effect.catchCause(cause =>
|
||||
Effect.sync(() => getLog().warn('recover', 'resume failed', { cause: String(cause) }))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Ctrl+C state machine (item 11) ──────────────────────────────────
|
||||
// While a turn runs, the first Ctrl+C STOPS the agent (session.interrupt);
|
||||
// a second Ctrl+C within QUIT_WINDOW_MS (or when idle) KILLS the TUI. The
|
||||
// debounce stops a stray Ctrl+C from nuking the session (opencode's
|
||||
// double-press model; the user's preferred behaviour).
|
||||
let quitArmed = false
|
||||
let quitTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let doQuit = () => {} // assigned once the renderer exists
|
||||
const disarmQuit = () => {
|
||||
quitArmed = false
|
||||
if (quitTimer) clearTimeout(quitTimer)
|
||||
quitTimer = undefined
|
||||
store.setHint(undefined)
|
||||
}
|
||||
const armQuit = (message: string) => {
|
||||
quitArmed = true
|
||||
store.setHint(message)
|
||||
if (quitTimer) clearTimeout(quitTimer)
|
||||
quitTimer = setTimeout(disarmQuit, QUIT_WINDOW_MS)
|
||||
}
|
||||
const interruptTurn = () => {
|
||||
const sid = gateway.sessionId()
|
||||
if (!sid) return
|
||||
Effect.runFork(
|
||||
gateway
|
||||
.request('session.interrupt', { session_id: sid })
|
||||
.pipe(
|
||||
Effect.catchCause(cause =>
|
||||
Effect.sync(() => getLog().warn('interrupt', 'failed', { cause: String(cause) }))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
const onCtrlC = () => {
|
||||
if (quitArmed) {
|
||||
disarmQuit()
|
||||
doQuit()
|
||||
return
|
||||
}
|
||||
if (store.state.info.running) {
|
||||
interruptTurn()
|
||||
armQuit('⏹ stopped — Ctrl+C again to quit')
|
||||
} else {
|
||||
armQuit('Ctrl+C again to quit')
|
||||
}
|
||||
}
|
||||
|
||||
// Transient hint that auto-clears (used by copy/image-paste feedback).
|
||||
const flashHint = (message: string, ms = 1500) => {
|
||||
store.setHint(message)
|
||||
setTimeout(() => {
|
||||
if (store.state.hint === message) store.setHint(undefined)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
// Copy a mouse selection to the clipboard (item 1) — OSC 52 + native command.
|
||||
// Copies exactly the rendered text the user highlighted (markers are concealed
|
||||
// in the pretty render; the `/copy` command copies a full response's source).
|
||||
const onCopySelection = (text: string) => {
|
||||
void writeClipboard(text)
|
||||
flashHint('Copied selection')
|
||||
}
|
||||
|
||||
// Paste an IMAGE (item 1): read the clipboard image and attach it to the
|
||||
// session (image.attach_bytes); the next prompt.submit picks it up.
|
||||
const onImagePaste = () => {
|
||||
void (async () => {
|
||||
const img = await readClipboardImage()
|
||||
if (!img) {
|
||||
flashHint('No image in clipboard', 2000)
|
||||
return
|
||||
}
|
||||
const sid = gateway.sessionId()
|
||||
if (!sid) {
|
||||
flashHint('No session for image', 2000)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
gateway.request('image.attach_bytes', {
|
||||
content_base64: img.data,
|
||||
filename: 'pasted.png',
|
||||
session_id: sid
|
||||
})
|
||||
)
|
||||
flashHint('🖼 image attached — type a message and send', 3000)
|
||||
} catch {
|
||||
flashHint('Image attach failed', 2000)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// A blocking prompt owns Ctrl+C (→ cancel); otherwise the state machine above runs.
|
||||
const { renderer, shutdown } = yield* acquireRenderer({
|
||||
mouse: input.mouse,
|
||||
isBlocked: () => store.state.prompt !== undefined,
|
||||
onCtrlC,
|
||||
onCopySelection
|
||||
})
|
||||
// Fleet memory self-sampling (HERMES_TUI_MEMLOG / diagnostics master
|
||||
// switch — boundary/memlog.ts). Scoped acquire→release like the renderer.
|
||||
const stopMemlog = startMemlog()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(stopMemlog))
|
||||
// Proactive idle GC (W2) — opt-in via a low HERMES_TUI_HEAP_MB (no-op on
|
||||
// the default path). Idle-gated on the store's streaming flag so it never
|
||||
// collects mid-reply. Scoped release like memlog.
|
||||
const proactiveGc = startProactiveGc(() => store.state.info.running === true)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(proactiveGc.stop))
|
||||
// Memory early-warning (#34095 parity) — surfaces a transcript system line
|
||||
// when heap climbs abnormally fast below the OOM ceiling (the silent-death
|
||||
// regime). ON by default: a KB user-facing safety heads-up, not a
|
||||
// diagnostic dump. No auto heap-snapshot (memlog is the diagnosis path).
|
||||
const stopMemoryMonitor = startMemoryMonitor(line => store.pushSystem(line))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(stopMemoryMonitor))
|
||||
// HERMES_HEAPDUMP_ON_START (Ink parity): a deliberate baseline snapshot at
|
||||
// boot. Bypasses the diagnostics master switch (you set it on purpose).
|
||||
// Best-effort + synchronous (writeHeapSnapshot blocks V8) — a failure must
|
||||
// never block launch.
|
||||
if (heapdumpOnStart()) {
|
||||
try {
|
||||
const dump = performHeapdump()
|
||||
store.pushSystem(`heap snapshot written: ${dump.path}`)
|
||||
} catch (cause) {
|
||||
getLog().warn('bootstrap', 'heapdump-on-start failed', { cause: String(cause) })
|
||||
}
|
||||
}
|
||||
doQuit = () => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
}
|
||||
|
||||
// Native keymap host (Phase 3): one keymap bound to this renderer, provided
|
||||
// to the whole Solid tree via <KeymapProvider>. Overlays/prompts register
|
||||
// close (and confirm) layers against it through useCloseLayer/useBindings.
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
|
||||
// Submit a user turn: the service value is in hand, so `gateway.request(...)`
|
||||
// is Effect<…, never> — fire it detached with runFork; failures are logged.
|
||||
const submitPrompt = (text: string) => {
|
||||
store.pushUser(text)
|
||||
const sid = gateway.sessionId()
|
||||
if (!sid) {
|
||||
getLog().warn('submit', 'no session yet — dropping prompt', { text })
|
||||
return
|
||||
}
|
||||
Effect.runFork(
|
||||
gateway
|
||||
.request('prompt.submit', { session_id: sid, text })
|
||||
.pipe(
|
||||
Effect.catchCause(cause => Effect.sync(() => getLog().warn('submit', 'failed', { cause: String(cause) })))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// `!cmd` — run a shell command directly (Ink/free-code parity: F9). The
|
||||
// gateway's `shell.exec` runs it (30s timeout, dangerous/hardline guards)
|
||||
// and returns {stdout, stderr, code}; we echo the invocation as a user line
|
||||
// and the combined output (or the error / non-zero exit) as a system line.
|
||||
// No model turn — this never hits prompt.submit. Detached like submitPrompt.
|
||||
const runShell = (cmd: string) => {
|
||||
if (!cmd) return
|
||||
store.pushUser(`!${cmd}`)
|
||||
Effect.runFork(
|
||||
gateway.request<{ stdout?: string; stderr?: string; code?: number }>('shell.exec', { command: cmd }).pipe(
|
||||
Effect.tap(r =>
|
||||
Effect.sync(() => {
|
||||
const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trimEnd()
|
||||
if (out) store.pushSystem(out)
|
||||
if ((r.code ?? 0) !== 0 || !out) store.pushSystem(`exit ${r.code ?? 0}`)
|
||||
})
|
||||
),
|
||||
Effect.catchCause(cause =>
|
||||
Effect.sync(() => {
|
||||
getLog().warn('shell', 'failed', { cause: String(cause) })
|
||||
store.pushSystem(`error: ${String(cause)}`)
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Resume a chosen session (resume picker pick or `/resume <id>` direct
|
||||
// path) — the same hydrate path as launch. When the picker was the BOOT
|
||||
// surface (bare `--resume`), no create ever ran, so the post-session
|
||||
// setup (catalog, /model prefetch) runs here exactly once.
|
||||
const onResume = (resumeSid: string) => {
|
||||
Effect.runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* resumeInto(gateway, store, resumeSid, input.cols)
|
||||
if (!store.state.catalog) yield* postSessionSetup(gateway, store, resumeSid)
|
||||
}).pipe(
|
||||
Effect.catchCause(cause => Effect.sync(() => getLog().warn('resume', 'failed', { cause: String(cause) })))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// The resume picker's gateway calls (view/overlays/sessionPicker.tsx).
|
||||
// `rename` goes through `session.title` — the existing title RPC (it
|
||||
// reaches only LIVE gateway sessions; the picker surfaces rejections).
|
||||
const sessionOps: SessionPickerOps = {
|
||||
list: params => Effect.runPromise(gateway.request('session.list', params)),
|
||||
peek: sessionId => Effect.runPromise(gateway.request('session.peek', { session_id: sessionId })),
|
||||
rename: (sessionId, title) =>
|
||||
Effect.runPromise(gateway.request('session.title', { session_id: sessionId, title })).then(() => undefined)
|
||||
}
|
||||
|
||||
// The background-process panel's gateway calls (view/overlays/backgroundPanel.tsx):
|
||||
// `agents.list` lists the OS process registry; `process.stop` kills ALL of them
|
||||
// (the gateway exposes kill-all only — no per-process RPC, hence no per-row kill).
|
||||
const backgroundOps = {
|
||||
list: () => Effect.runPromise(gateway.request('agents.list', {})).then(parseProcessList),
|
||||
stopAll: () => Effect.runPromise(gateway.request('process.stop', {})).then(() => undefined)
|
||||
}
|
||||
|
||||
// Boot-picker Esc fallback: the picker closed without a pick and no
|
||||
// session exists yet (bare `--resume` launch) — create a fresh one so
|
||||
// the composer has somewhere to send prompts.
|
||||
const onSessionPickerClosed = () => {
|
||||
if (gateway.sessionId()) return
|
||||
Effect.runFork(
|
||||
createFreshSession(gateway, store, input).pipe(
|
||||
Effect.catchCause(cause =>
|
||||
Effect.sync(() => getLog().warn('bootstrap', 'post-picker create failed', { cause: String(cause) }))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Slash dispatch context (Solid logic; the boundary just hands it a
|
||||
// Promise-returning `request` + the host capabilities it needs).
|
||||
const slashCtx: SlashContext = {
|
||||
clearTranscript: () => store.clearTranscript(),
|
||||
compact: () => store.state.compact,
|
||||
setCompact: on => store.setCompact(on),
|
||||
details: () => store.state.details,
|
||||
setDetails: mode => store.setDetails(mode),
|
||||
renderableCount: () => {
|
||||
try {
|
||||
return descendantCount(renderer.root)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
// HERMES_TUI_NO_CONFIRM (Ink parity): skip the destructive-action confirm
|
||||
// step and run the action immediately. Read per call so a wrapper that
|
||||
// mutates env before launch sees the live value.
|
||||
confirm: (message, onConfirm) => (noConfirmDestructive() ? onConfirm() : store.setConfirm(message, onConfirm)),
|
||||
copyResponse: n => {
|
||||
const text = nthAssistantResponse(store.state.messages, n)
|
||||
if (!text) return false
|
||||
void writeClipboard(text)
|
||||
flashHint(n > 1 ? `Copied response #${n} to clipboard` : 'Copied response to clipboard')
|
||||
return true
|
||||
},
|
||||
modelItems: () => store.state.modelItems,
|
||||
setModelItems: items => store.setModelItems(items),
|
||||
logTail: () =>
|
||||
getLog()
|
||||
.tail(200)
|
||||
.map(e => `${e.scope}: ${e.msg}`),
|
||||
openDashboard: () => store.openDashboard(),
|
||||
openBackgroundPanel: () => store.openBackgroundPanel(),
|
||||
addBgTask: id => store.addBgTask(id),
|
||||
openPager: (title, text) => store.openPager(title, text),
|
||||
openPicker: picker => store.openPicker(picker),
|
||||
openSessionPicker: tab => store.openSessionPicker(tab),
|
||||
resumeSession: onResume,
|
||||
pushSystem: text => store.pushSystem(text),
|
||||
quit: () => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
},
|
||||
request: (method, params) => Effect.runPromise(gateway.request(method, params)),
|
||||
sessionId: () => gateway.sessionId(),
|
||||
submit: submitPrompt
|
||||
}
|
||||
|
||||
// The composer's submit: `!cmd` runs a shell command (F9), `/command`
|
||||
// routes through the slash ladder, else a prompt turn.
|
||||
const submit = (text: string) => {
|
||||
const route = classifySubmit(text)
|
||||
if (route.kind === 'shell') runShell(route.payload)
|
||||
else if (route.kind === 'slash') void dispatchSlash(route.payload, slashCtx)
|
||||
else submitPrompt(route.payload)
|
||||
}
|
||||
|
||||
// Live completions (items 5 + 13): a `/command [args]` line queries
|
||||
// `complete.slash` (the gateway completes names AND args); a trailing
|
||||
// path-like word queries `complete.path` (file/@-mention tagging). The
|
||||
// accepted item replaces from the gateway's `replace_from` (or the token
|
||||
// start), so only the relevant token is spliced — not the whole line.
|
||||
// Fired per keystroke (a debounce is a polish item).
|
||||
//
|
||||
// Out-of-order guard (glitch 2026-06-14): the gateway transport does NOT
|
||||
// guarantee in-order response delivery, and these RPCs fire per keystroke
|
||||
// with no debounce — a slow earlier `complete.slash` could resolve AFTER a
|
||||
// later `@`-mention `complete.path` and clobber the store, blanking the
|
||||
// `@` dropdown ("a leading /path message breaks @-mentions afterward").
|
||||
// The completion gate (claimed on EVERY call, before the clear branch, so
|
||||
// an intermediate keystroke that fires no RPC still invalidates the older
|
||||
// in-flight one) drops any response a newer keystroke has superseded.
|
||||
const completionGate = createCompletionGate()
|
||||
const onType = (text: string, cursor: number = text.length) => {
|
||||
const token = completionGate.claim()
|
||||
const plan = planCompletion(text, cursor)
|
||||
if (!plan) {
|
||||
store.clearCompletions()
|
||||
return
|
||||
}
|
||||
Effect.runPromise(gateway.request(plan.method, plan.params))
|
||||
.then(result => {
|
||||
if (!completionGate.isCurrent(token)) return // a newer keystroke superseded this query
|
||||
store.setCompletions(mapCompletions(result), readReplaceFrom(result, plan.from))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!completionGate.isCurrent(token)) return
|
||||
store.clearCompletions()
|
||||
})
|
||||
}
|
||||
|
||||
// Blocking-prompt replies (clarify/approval/sudo/secret `*.respond`). Same
|
||||
// detached-runFork pattern; failures logged, never thrown into the view.
|
||||
const respond = (method: string, params: Record<string, unknown>) => {
|
||||
Effect.runFork(
|
||||
gateway
|
||||
.request(method, params)
|
||||
.pipe(
|
||||
Effect.catchCause(cause =>
|
||||
Effect.sync(() => getLog().warn('respond', 'failed', { cause: String(cause), method }))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Live backend: drive a session (create + optional initial prompt) concurrently.
|
||||
if (!input.fake) yield* Effect.forkScoped(bootstrapSession(gateway, store, input))
|
||||
|
||||
// (No ambient OS-process poll: the `bg:` badge now counts in-flight
|
||||
// background-PROMPT tasks from the event stream, and the /processes panel
|
||||
// fetches `agents.list` on open. Nothing to poll for.)
|
||||
|
||||
// Contact point #1: the single render bridge. After this, the screen is Solid's.
|
||||
// The theme is sourced reactively from the store (skin events update it).
|
||||
yield* Effect.promise(() =>
|
||||
render(
|
||||
() => (
|
||||
<KeymapProvider keymap={keymap}>
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<TerminalChrome store={store} />
|
||||
<App
|
||||
store={store}
|
||||
onSubmit={submit}
|
||||
onType={onType}
|
||||
onRespond={respond}
|
||||
onResume={onResume}
|
||||
sessionOps={sessionOps}
|
||||
onSessionPickerClosed={onSessionPickerClosed}
|
||||
sessionId={() => gateway.sessionId()}
|
||||
history={history}
|
||||
onImagePaste={onImagePaste}
|
||||
pasteStore={pasteStore}
|
||||
backgroundOps={backgroundOps}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</KeymapProvider>
|
||||
),
|
||||
renderer
|
||||
)
|
||||
)
|
||||
|
||||
// Block until the renderer is destroyed (Ctrl+C / quit); finalizers then run.
|
||||
yield* Deferred.await(shutdown)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
/** Scripted "hello" stream so the fake backend paints a non-empty frame offline. */
|
||||
function streamHello(controller: FakeGatewayController): void {
|
||||
controller.emit({ type: 'gateway.ready' })
|
||||
controller.emit({ type: 'message.start' })
|
||||
for (const chunk of ['Hi ', 'there, ', 'glitch!']) {
|
||||
controller.emit({ type: 'message.delta', payload: { text: chunk } })
|
||||
}
|
||||
controller.emit({ type: 'message.complete' })
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const fake = envFlag(process.env.HERMES_TUI_FAKE, false)
|
||||
const cols = process.stdout.columns || 80
|
||||
// `hermes --tui "prompt"` / `--image` seed: the launcher sets HERMES_TUI_QUERY
|
||||
// (+ HERMES_TUI_IMAGE); we also honor HERMES_TUI_PROMPT (OpenTUI alias) and a
|
||||
// bare argv tail (standalone dev). See logic/env.ts startupPrompt/startupImage.
|
||||
const initialPrompt = startupPrompt()
|
||||
const initialImage = startupImage()
|
||||
const resumeId = process.env.HERMES_TUI_RESUME?.trim()
|
||||
// Mouse on by default. Defers to Ink's env surface (HERMES_TUI_MOUSE_TRACKING >
|
||||
// HERMES_TUI_DISABLE_MOUSE > HERMES_TUI_MOUSE alias > default on). See env.ts.
|
||||
const mouse = resolveMouseEnabled()
|
||||
const base = { mouse, fake, cols }
|
||||
const withPrompt = initialPrompt ? { ...base, initialPrompt } : base
|
||||
const withImage = initialImage ? { ...withPrompt, initialImage } : withPrompt
|
||||
const input: TuiInput = resumeId ? { ...withImage, resumeId } : withImage
|
||||
|
||||
const onFatal = (error: unknown) => {
|
||||
getLog().error('entry', 'fatal', { error: String(error) })
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (fake) {
|
||||
const { layer, controller } = makeFakeGatewayLayer()
|
||||
// Drive the fake stream shortly after mount so the subscription is live.
|
||||
setTimeout(() => streamHello(controller), 50)
|
||||
Effect.runPromise(run(input).pipe(Effect.provide(makeAppLayer(layer)))).catch(onFatal)
|
||||
} else {
|
||||
Effect.runPromise(run(input).pipe(Effect.provide(makeAppLayer(liveGatewayLayer)))).catch(onFatal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Background-activity logic — pure parsers + derive helpers for the "ambient
|
||||
* activity" feature (notifications, long-running processes, background runs).
|
||||
* No state container here: the store owns the arrays; these functions parse
|
||||
* loose wire payloads (everything off the gateway is `unknown`) and compute
|
||||
* derived values over immutable arrays. Mirrors the defensive loose-read style
|
||||
* of `logic/slash.ts` (`readStr`) and the snake_case→camel mapping the wire
|
||||
* needs.
|
||||
*
|
||||
* Wire shapes (see boundary/schema/GatewayEvent.ts ~134):
|
||||
* notification.show payload {text, level, kind, ttl_ms, key, id} (loose Record)
|
||||
* notification.clear payload {key}
|
||||
* agents.list result {processes:[{session_id, command, status, uptime_seconds}]}
|
||||
*/
|
||||
|
||||
export interface ActivityNotification {
|
||||
id: string
|
||||
key?: string
|
||||
text: string
|
||||
level: 'info' | 'warn' | 'error' | 'success'
|
||||
kind: string
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export interface BackgroundProcess {
|
||||
sessionId: string
|
||||
command: string
|
||||
status: string
|
||||
uptimeSeconds: number
|
||||
}
|
||||
|
||||
/** Loose-read a string field off an `unknown` object (slash.ts `readStr` style). */
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
/** Loose-read a finite number off an `unknown` object. */
|
||||
function readNum(value: unknown, key: string): number | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||
}
|
||||
|
||||
/** Coerce any wire `level` to the closed union; anything that isn't a known
|
||||
* level (absent, garbage, wrong-typed) falls back to 'info'. */
|
||||
function coerceLevel(value: unknown): ActivityNotification['level'] {
|
||||
return value === 'warn' || value === 'error' || value === 'success' ? value : 'info'
|
||||
}
|
||||
|
||||
/** A chrome notice (status-bar banner with lifecycle), distinguished from an
|
||||
* inline card by its lifecycle kind. Credits/usage notices set kind sticky|ttl;
|
||||
* process/background cards use label kinds (process.complete, etc.). */
|
||||
export function isChromeNotice(n: ActivityNotification): boolean {
|
||||
return n.kind === 'sticky' || n.kind === 'ttl'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `notification.show` payload (unknown) → ActivityNotification, or null
|
||||
* when there's no usable text (text is the load-bearing field — without it the
|
||||
* card has nothing to show). Maps snake_case `ttl_ms` → `ttlMs`, coerces a
|
||||
* garbage/missing `level` to 'info', and defaults `kind` to ''.
|
||||
*
|
||||
* id resolution: prefer the wire `id`, then fall back to `key`, else synthesize
|
||||
* `id = `n:${text}`` (a stable, text-derived id rather than a random one). The
|
||||
* original `key` (if any) is preserved separately so notification.clear by key
|
||||
* still targets the right cards.
|
||||
*/
|
||||
export function parseNotification(payload: unknown): ActivityNotification | null {
|
||||
const text = readStr(payload, 'text')
|
||||
if (!text) return null
|
||||
const key = readStr(payload, 'key')
|
||||
const id = readStr(payload, 'id') ?? key ?? `n:${text}`
|
||||
const out: ActivityNotification = {
|
||||
id,
|
||||
kind: readStr(payload, 'kind') ?? '',
|
||||
level: coerceLevel((payload as { level?: unknown } | null | undefined)?.level),
|
||||
text
|
||||
}
|
||||
if (key !== undefined) out.key = key
|
||||
const ttlMs = readNum(payload, 'ttl_ms')
|
||||
if (ttlMs !== undefined) out.ttlMs = ttlMs
|
||||
return out
|
||||
}
|
||||
|
||||
/** Parse an `agents.list` result ({processes:[...]}) → BackgroundProcess[],
|
||||
* skipping malformed rows (a row missing session_id/command is dropped, not
|
||||
* defaulted). snake_case `session_id`/`uptime_seconds` → camelCase; a missing
|
||||
* uptime defaults to 0, a missing status to ''. */
|
||||
export function parseProcessList(result: unknown): BackgroundProcess[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const processes = (result as { processes?: unknown }).processes
|
||||
if (!Array.isArray(processes)) return []
|
||||
const out: BackgroundProcess[] = []
|
||||
for (const row of processes) {
|
||||
const sessionId = readStr(row, 'session_id')
|
||||
const command = readStr(row, 'command')
|
||||
if (!sessionId || !command) continue
|
||||
out.push({
|
||||
command,
|
||||
sessionId,
|
||||
status: readStr(row, 'status') ?? '',
|
||||
uptimeSeconds: readNum(row, 'uptime_seconds') ?? 0
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Terminal (no-longer-running) process statuses. A process whose status is
|
||||
* NOT one of these is treated as running — leniently, because the gateway's
|
||||
* status vocabulary is open-ended and we'd rather over-count the ambient badge
|
||||
* than silently hide a still-live process under an unfamiliar status string.
|
||||
* Matched case-insensitively after trimming. */
|
||||
/** Terminal (no-longer-running) process statuses — exported as the single
|
||||
* source of truth (the panel imports `procIsRunning` rather than re-declaring). */
|
||||
export const DONE_STATUSES = new Set(['exited', 'failed', 'complete', 'done', 'killed'])
|
||||
|
||||
/** Whether a process status is "running-ish": NOT one of DONE_STATUSES. Lenient
|
||||
* by design — the gateway's status vocabulary is open-ended, so we over-count
|
||||
* rather than hide a live process under an unfamiliar status. Case-insensitive. */
|
||||
export function procIsRunning(status: string): boolean {
|
||||
return !DONE_STATUSES.has(status.trim().toLowerCase())
|
||||
}
|
||||
|
||||
/** Count of running background processes (the ambient `bg:` badge). */
|
||||
export function runningCount(procs: readonly BackgroundProcess[]): number {
|
||||
return procs.filter(p => procIsRunning(p.status)).length
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Completion-menu key routing (Epic 8) — the pure decision table for the
|
||||
* composer's completions dropdown, kept out of the view so the precedence
|
||||
* rules are unit-testable.
|
||||
*
|
||||
* Precedence (the hard part):
|
||||
* - Tab accepts the highlighted item and Esc dismisses whenever ANY menu is
|
||||
* open (slash-command OR path/@-mention) — the pre-Epic-8 semantics.
|
||||
* - Up/Down move the highlight (wrapping) and Enter accepts it ONLY for the
|
||||
* SLASH menu (the composer's first token starts with `/`). On a path menu
|
||||
* — or with a Ctrl/Alt-modified key — they `pass`, keeping their existing
|
||||
* meanings (prompt history, cursor moves, textarea submit).
|
||||
* - A closed menu (`count === 0`) always passes.
|
||||
*
|
||||
* The caller owns the side effects: `move` updates the selection signal,
|
||||
* `accept` splices the item into the composer (then arg-completion continues
|
||||
* as before), `dismiss` clears the candidates, `pass` falls through to the
|
||||
* history/cursor handling.
|
||||
*/
|
||||
|
||||
/** Max dropdown rows shown (the view slices candidates to this). */
|
||||
export const MENU_MAX = 8
|
||||
|
||||
export interface MenuKeyContext {
|
||||
/** Number of VISIBLE candidates (already capped at MENU_MAX). */
|
||||
count: number
|
||||
/** The currently highlighted row. */
|
||||
selected: number
|
||||
/** Whether this is the slash-command menu (composer text starts with `/`). */
|
||||
slashMenu: boolean
|
||||
}
|
||||
|
||||
export type MenuKeyAction =
|
||||
| { kind: 'move'; selected: number }
|
||||
| { kind: 'accept'; index: number }
|
||||
| { kind: 'dismiss' }
|
||||
| { kind: 'pass' }
|
||||
|
||||
const PASS: MenuKeyAction = { kind: 'pass' }
|
||||
|
||||
/** Clamp the selection into the visible range (a shrunken list can strand it). */
|
||||
function clampSelected(ctx: MenuKeyContext): number {
|
||||
return Math.min(Math.max(0, ctx.selected), ctx.count - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route one key press against the open menu. `modified` is Ctrl/Alt/Option —
|
||||
* modified arrows/Enter never belong to the menu (Tab/Esc keep their
|
||||
* pre-existing modifier-blind accept/dismiss semantics).
|
||||
*
|
||||
* ANY open menu owns plain arrows/Enter (glitch, 2026-06-10): @-path and
|
||||
* arg menus navigate exactly like the slash menu — standard editor-
|
||||
* autocomplete behavior; Esc dismisses to hand the cursor keys back.
|
||||
* (`ctx.slashMenu` still feeds the hint text + suggestion rows.)
|
||||
*/
|
||||
export function routeMenuKey(name: string, modified: boolean, ctx: MenuKeyContext): MenuKeyAction {
|
||||
if (ctx.count <= 0) return PASS
|
||||
if (name === 'tab') return { index: clampSelected(ctx), kind: 'accept' }
|
||||
if (name === 'escape') return { kind: 'dismiss' }
|
||||
if (modified) return PASS
|
||||
const sel = clampSelected(ctx)
|
||||
if (name === 'up') return { kind: 'move', selected: (sel - 1 + ctx.count) % ctx.count }
|
||||
if (name === 'down') return { kind: 'move', selected: (sel + 1) % ctx.count }
|
||||
if (name === 'return') return { index: sel, kind: 'accept' }
|
||||
return PASS
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Assistant-text extraction (the `/copy [n]` command's pure logic). An assistant
|
||||
* turn's answer lives in `parts` (the `type:'text'` fragments, concatenated) while
|
||||
* live, OR in `.text` once settled/resumed. We copy the ANSWER only — reasoning and
|
||||
* tool parts are excluded. `nthAssistantResponse` indexes newest-first (1-based).
|
||||
*
|
||||
* NB: mouse-selection copies the RENDERED text verbatim (native OpenTUI selection,
|
||||
* `selection.getSelectedText()`), not markdown source — markers are concealed in the
|
||||
* pretty render and can't be recovered from a partial selection (user's choice). The
|
||||
* source-bearing path is this `/copy` command, which copies a whole response's source.
|
||||
*/
|
||||
import type { Message } from './store.ts'
|
||||
|
||||
/** The answer text of one message: concat the `text` parts (trimmed) when live, else `.text`. */
|
||||
export function messageText(m: Message): string {
|
||||
if (m.parts && m.parts.length) {
|
||||
return m.parts
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
return m.text
|
||||
}
|
||||
|
||||
/** Newest-first list of the non-empty answer text for every assistant message. */
|
||||
export function assistantResponses(messages: Message[]): string[] {
|
||||
const out: string[] = []
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (!m || m.role !== 'assistant') continue
|
||||
const text = messageText(m)
|
||||
if (text) out.push(text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The n-th newest assistant response (1-based; n=1 → last). `undefined` if out of range. */
|
||||
export function nthAssistantResponse(messages: Message[], n: number): string | undefined {
|
||||
return assistantResponses(messages)[n - 1]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* deferClose — defer an overlay/prompt close by one tick.
|
||||
*
|
||||
* Overlays REPLACE the composer (a `<Switch>`), so when one closes the composer
|
||||
* remounts + refocuses. Running the close on the NEXT tick lets the current
|
||||
* key/close event (Esc/q/Enter/y/select) finish dispatching first, so the
|
||||
* keystroke that triggered the close can't leak into the freshly-focused
|
||||
* composer (e.g. `/clear`→y once left a stray "y" in the input).
|
||||
*/
|
||||
export function deferClose(fn: () => void): void {
|
||||
setTimeout(fn, 0)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Global detail-mode logic (/details — Epic 3 utility-command port; mirrors Ink
|
||||
* `domain/details.ts`, GLOBAL mode only — per-section overrides are explicitly
|
||||
* deferred). The mode drives how the transcript treats tool + reasoning rows:
|
||||
*
|
||||
* - `collapsed` (default): today's behaviour — headers with click-to-expand.
|
||||
* - `expanded`: tool bodies + settled reasoning previews default-OPEN.
|
||||
* - `hidden`: tool/reasoning runs reduce to ONE muted `⚡ N tools hidden`-style
|
||||
* line per run (never silently dropped — flipping the mode back restores,
|
||||
* since the parts stay in the store untouched).
|
||||
*
|
||||
* Pure data + functions; the store carries the flag, messageLine/toolPart/
|
||||
* reasoningPart read it via the display context.
|
||||
*/
|
||||
import type { Part } from './store.ts'
|
||||
|
||||
export type DetailsMode = 'hidden' | 'collapsed' | 'expanded'
|
||||
|
||||
/** Cycle order (Ink parity: hidden → collapsed → expanded → hidden). */
|
||||
export const DETAILS_MODES = ['hidden', 'collapsed', 'expanded'] as const
|
||||
|
||||
/** Gateway `complete.slash` suggests these per-section names after `/details ` —
|
||||
* recognized so picking one yields an honest "not supported yet" notice instead
|
||||
* of the generic usage line (per-section overrides are deferred). */
|
||||
export const DETAILS_SECTIONS = ['thinking', 'tools', 'subagents', 'activity'] as const
|
||||
|
||||
export const DETAILS_USAGE = 'usage: /details [hidden|collapsed|expanded|cycle]'
|
||||
|
||||
/** Parse a mode word; null for anything unrecognized (non-strings included). */
|
||||
export function parseDetailsMode(v: unknown): DetailsMode | null {
|
||||
if (typeof v !== 'string') return null
|
||||
const norm = v.trim().toLowerCase()
|
||||
return DETAILS_MODES.find(m => m === norm) ?? null
|
||||
}
|
||||
|
||||
/** The next mode in the cycle (`/details cycle`). */
|
||||
export function nextDetailsMode(m: DetailsMode): DetailsMode {
|
||||
return DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length] ?? 'collapsed'
|
||||
}
|
||||
|
||||
/** One collapsed RUN of consecutive tool/reasoning parts (hidden mode). */
|
||||
export interface HiddenRun {
|
||||
type: 'hiddenRun'
|
||||
/** Stable-ish key: the first hidden part's id. */
|
||||
id: string
|
||||
tools: number
|
||||
thoughts: number
|
||||
}
|
||||
|
||||
/** What the transcript renders per part slot: a real part, or a hidden-run marker. */
|
||||
export type DisplayPart = Part | HiddenRun
|
||||
|
||||
/**
|
||||
* Hidden mode: keep text parts, fold each consecutive run of tool/reasoning
|
||||
* parts into ONE HiddenRun marker (so a 5-tool fan-out reads as a single muted
|
||||
* line, not 5 of them). Pure — the source parts are untouched, so switching
|
||||
* the mode back restores everything.
|
||||
*/
|
||||
export function collapseHiddenParts(parts: readonly Part[]): DisplayPart[] {
|
||||
const out: DisplayPart[] = []
|
||||
let run: HiddenRun | undefined
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
run = undefined
|
||||
out.push(part)
|
||||
continue
|
||||
}
|
||||
if (!run) {
|
||||
run = { id: `hidden-${part.id}`, thoughts: 0, tools: 0, type: 'hiddenRun' }
|
||||
out.push(run)
|
||||
}
|
||||
if (part.type === 'tool') run.tools += 1
|
||||
else run.thoughts += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Muted one-liner for a hidden run: `2 tools · 1 thought hidden`. */
|
||||
export function hiddenRunLabel(run: HiddenRun): string {
|
||||
const segs: string[] = []
|
||||
if (run.tools) segs.push(`${run.tools} tool${run.tools === 1 ? '' : 's'}`)
|
||||
if (run.thoughts) segs.push(`${run.thoughts} thought${run.thoughts === 1 ? '' : 's'}`)
|
||||
return `${segs.join(' · ')} hidden — /details collapsed to show`
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Process diagnostics for the /mem + /heapdump utility commands (Epic 3 port;
|
||||
* Ink ref `app/slash/commands/debug.ts` + `lib/memory.ts`). Pure formatters plus
|
||||
* the one impure seam (`performHeapdump` → `v8.writeHeapSnapshot`), kept out of
|
||||
* slash.ts so the dispatcher stays plain and tests can mock `node:v8`.
|
||||
*/
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { writeHeapSnapshot } from 'node:v8'
|
||||
|
||||
/** `123456789` → `117.7 MB` (binary units, one decimal above bytes). */
|
||||
export function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '0 B'
|
||||
if (n < 1024) return `${Math.round(n)} B`
|
||||
const units = ['KB', 'MB', 'GB', 'TB'] as const
|
||||
let v = n
|
||||
let i = -1
|
||||
do {
|
||||
v /= 1024
|
||||
i += 1
|
||||
} while (v >= 1024 && i < units.length - 1)
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
/** Where heap snapshots land: `$HERMES_HOME`/`~/.hermes` + `logs/opentui-heap-<ts>.heapsnapshot`. */
|
||||
export function heapSnapshotPath(now = new Date()): string {
|
||||
const home = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
|
||||
const ts = now.toISOString().replace(/[:.]/g, '-')
|
||||
return join(home, 'logs', `opentui-heap-${ts}.heapsnapshot`)
|
||||
}
|
||||
|
||||
export interface HeapdumpResult {
|
||||
path: string
|
||||
before: { heapUsed: number; rss: number }
|
||||
after: { heapUsed: number; rss: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a V8 heap snapshot (SYNCHRONOUS — blocks the event loop while V8 walks
|
||||
* the heap; that's inherent to writeHeapSnapshot) and report heap/rss before
|
||||
* vs after. Throws on I/O failure — the caller renders the error.
|
||||
*/
|
||||
export function performHeapdump(): HeapdumpResult {
|
||||
const before = process.memoryUsage()
|
||||
const path = heapSnapshotPath()
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
const written = writeHeapSnapshot(path)
|
||||
const after = process.memoryUsage()
|
||||
return {
|
||||
after: { heapUsed: after.heapUsed, rss: after.rss },
|
||||
before: { heapUsed: before.heapUsed, rss: before.rss },
|
||||
path: written
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemSnapshot {
|
||||
heapUsed: number
|
||||
heapTotal: number
|
||||
external: number
|
||||
arrayBuffers: number
|
||||
rss: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The /mem system-line body (Ink's Memory panel as aligned rows). `renderables`
|
||||
* is the mounted-renderable count under the live renderer root (the store-cap
|
||||
* diagnostic) — omitted when unavailable (e.g. no renderer in tests).
|
||||
*/
|
||||
export function memReport(usage: MemSnapshot, uptimeSeconds: number, renderables?: number): string {
|
||||
const rows: Array<[string, string]> = [
|
||||
['heap used', formatBytes(usage.heapUsed)],
|
||||
['heap total', formatBytes(usage.heapTotal)],
|
||||
['external', formatBytes(usage.external)],
|
||||
['array buffers', formatBytes(usage.arrayBuffers)],
|
||||
['rss', formatBytes(usage.rss)],
|
||||
['uptime', `${Math.round(uptimeSeconds)}s`]
|
||||
]
|
||||
if (renderables !== undefined) rows.push(['renderables', String(renderables)])
|
||||
const pad = Math.max(...rows.map(([k]) => k.length))
|
||||
return ['memory', ...rows.map(([k, v]) => ` ${k.padEnd(pad)} ${v}`)].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Pure unified-diff helpers for the file-tool renderer (Epic 2.3). No
|
||||
* OpenTUI/Solid imports — just string work, trivially unit-testable (like
|
||||
* `toolOutput.ts`). The gateway ships the FULL raw unified diff on file-edit
|
||||
* `tool.complete` (`diff_unified`); these helpers turn it into the collapsed
|
||||
* `+N −M` summary and per-file sections for the native `<diff>` renderable
|
||||
* (which parses only the FIRST file of a multi-file diff — so we split).
|
||||
*/
|
||||
|
||||
/** Added/removed line counts for the collapsed header summary (`+N −M`). */
|
||||
export interface DiffStats {
|
||||
added: number
|
||||
removed: number
|
||||
}
|
||||
|
||||
/** Count changed lines in a unified diff, excluding the `+++`/`---` file headers. */
|
||||
export function diffStats(diff: string): DiffStats {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) continue
|
||||
if (line.startsWith('+')) added++
|
||||
else if (line.startsWith('-')) removed++
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Path relative to the session cwd: exact prefix strip only (no `~` for home —
|
||||
* deliberately simple). Paths outside cwd come back unchanged; the cwd itself
|
||||
* becomes `.`. A trailing slash on cwd is tolerated.
|
||||
*/
|
||||
export function relativizePath(path: string, cwd?: string): string {
|
||||
if (!path || !cwd) return path
|
||||
const base = cwd.endsWith('/') && cwd !== '/' ? cwd.slice(0, -1) : cwd
|
||||
if (path === base) return '.'
|
||||
const prefix = base === '/' ? '/' : base + '/'
|
||||
if (path.startsWith(prefix)) return path.slice(prefix.length) || '.'
|
||||
return path
|
||||
}
|
||||
|
||||
/** One file's section of a (possibly multi-file) unified diff. */
|
||||
export interface DiffFileSection {
|
||||
/** Target path from the `+++ b/…` header (or `--- a/…` for deletions); '' if unknown. */
|
||||
path: string
|
||||
/** The section's unified diff text, parseable on its own. */
|
||||
diff: string
|
||||
}
|
||||
|
||||
/** Extract the path from a `--- a/x` / `+++ b/x` header line ('' for /dev/null). */
|
||||
function headerPath(line: string): string {
|
||||
let p = line.slice(4).trim()
|
||||
const tab = p.indexOf('\t') // difflib may append a date after a tab
|
||||
if (tab !== -1) p = p.slice(0, tab)
|
||||
if (!p || p === '/dev/null') return ''
|
||||
if (p.startsWith('a/') || p.startsWith('b/')) p = p.slice(2)
|
||||
return p
|
||||
}
|
||||
|
||||
function sectionPath(lines: string[]): string {
|
||||
const to = lines.find(l => l.startsWith('+++ '))
|
||||
const from = lines.find(l => l.startsWith('--- '))
|
||||
return (to ? headerPath(to) : '') || (from ? headerPath(from) : '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a unified diff into per-file sections (the gateway concatenates one
|
||||
* difflib diff per edited file; `patch`-mode diffs can also be multi-file). A
|
||||
* new section starts at a `--- ` header — required to be FOLLOWED by `+++ `
|
||||
* and to come after the current section's hunks, so removed lines that merely
|
||||
* start with `--` can't split a file in half.
|
||||
*/
|
||||
export function splitUnifiedDiff(diff: string): DiffFileSection[] {
|
||||
const lines = diff.replace(/\n$/, '').split('\n')
|
||||
const sections: string[][] = []
|
||||
let current: string[] = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? ''
|
||||
if (current.some(l => l.startsWith('@@')) && line.startsWith('--- ') && (lines[i + 1] ?? '').startsWith('+++ ')) {
|
||||
sections.push(current)
|
||||
current = []
|
||||
}
|
||||
current.push(line)
|
||||
}
|
||||
if (current.length > 0) sections.push(current)
|
||||
return sections.filter(s => s.some(l => l.startsWith('@@'))).map(s => ({ diff: s.join('\n'), path: sectionPath(s) }))
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* env — shared boolean env-flag parsing (one source for the TRUE/FALSE regexes).
|
||||
*
|
||||
* Recognized truthy values: 1/true/yes/on; falsy: 0/false/no/off (case-insensitive,
|
||||
* surrounding whitespace trimmed). Anything else (incl. unset) is "unrecognized".
|
||||
*/
|
||||
export const TRUE_RE = /^(?:1|true|yes|on)$/i
|
||||
export const FALSE_RE = /^(?:0|false|no|off)$/i
|
||||
|
||||
/** Parse a boolean env var; returns `fallback` when unset/unrecognized. */
|
||||
export function envFlag(value: string | undefined, fallback: boolean): boolean {
|
||||
const v = value?.trim() ?? ''
|
||||
if (TRUE_RE.test(v)) return true
|
||||
if (FALSE_RE.test(v)) return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri-state toggle parse: `true`/`false` for a recognized value, `null` when
|
||||
* unset/unrecognized (so a caller can fall through to the next precedence rung).
|
||||
* Mirrors Ink's `parseToggle` (`ui-tui/src/config/env.ts`).
|
||||
*/
|
||||
export function envToggle(value: string | undefined): boolean | null {
|
||||
const v = value?.trim() ?? ''
|
||||
if (TRUE_RE.test(v)) return true
|
||||
if (FALSE_RE.test(v)) return false
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether mouse tracking is ON at boot, deferring to Ink's env surface
|
||||
* (`ui-tui/src/config/env.ts`) so muscle memory + docs + support scripts carry
|
||||
* over. Precedence (highest first):
|
||||
* 1. `HERMES_TUI_MOUSE_TRACKING` (toggle) — the explicit force knob; beats all.
|
||||
* 2. `HERMES_TUI_DISABLE_MOUSE=1` — the legacy Ink kill switch (off).
|
||||
* 3. `HERMES_TUI_MOUSE` (toggle) — the OpenTUI-native alias (kept, rule 2);
|
||||
* it's also what the launcher sets, so it stays a first-class boot knob.
|
||||
* 4. default ON (opencode parity: wheel-scroll, drag-scrollbar, click-to-expand,
|
||||
* text-aware selection).
|
||||
* OpenTUI's renderer mouse is a single boolean, so Ink's granular off|wheel|
|
||||
* buttons|all collapses to on/off here (any non-off tracking mode → on).
|
||||
*/
|
||||
export function resolveMouseEnabled(env: { readonly [k: string]: string | undefined } = process.env): boolean {
|
||||
const trackingOverride = envToggle(env.HERMES_TUI_MOUSE_TRACKING)
|
||||
if (trackingOverride !== null) return trackingOverride
|
||||
if (envFlag(env.HERMES_TUI_DISABLE_MOUSE, false)) return false
|
||||
const mouseAlias = envToggle(env.HERMES_TUI_MOUSE)
|
||||
if (mouseAlias !== null) return mouseAlias
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The seeded initial prompt for `hermes --tui "prompt"` / `--image`.
|
||||
*
|
||||
* The launcher (`hermes_cli/main.py`) sets `HERMES_TUI_QUERY` (the established
|
||||
* cross-engine contract Ink reads via `STARTUP_QUERY`); the OpenTUI engine also
|
||||
* accepts `HERMES_TUI_PROMPT` as its own alias and a bare argv tail for
|
||||
* standalone dev launches. QUERY wins (it's the launcher contract); PROMPT and
|
||||
* argv are fallbacks. Empty → undefined.
|
||||
*/
|
||||
export function startupPrompt(
|
||||
env: { readonly [k: string]: string | undefined } = process.env,
|
||||
argv: readonly string[] = process.argv.slice(2)
|
||||
): string | undefined {
|
||||
const query = env.HERMES_TUI_QUERY?.trim()
|
||||
if (query) return query
|
||||
const prompt = env.HERMES_TUI_PROMPT?.trim()
|
||||
if (prompt) return prompt
|
||||
const tail = argv.join(' ').trim()
|
||||
return tail || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The seeded image PATH for `hermes --tui --image <path>`. The launcher sets
|
||||
* `HERMES_TUI_IMAGE` (Ink reads it as `STARTUP_IMAGE` and `image.attach`es the
|
||||
* path before submitting the query). Empty → undefined.
|
||||
*/
|
||||
export function startupImage(env: { readonly [k: string]: string | undefined } = process.env): string | undefined {
|
||||
const image = env.HERMES_TUI_IMAGE?.trim()
|
||||
return image || undefined
|
||||
}
|
||||
|
||||
/** Ink's default prompt when an image is seeded with no query (`STARTUP_QUERY`). */
|
||||
export const STARTUP_IMAGE_DEFAULT_PROMPT = 'What do you see in this image?'
|
||||
|
||||
/**
|
||||
* `HERMES_TUI_NO_CONFIRM` — skip destructive-action confirm prompts (Ink parity,
|
||||
* `ui-tui/src/config/env.ts` `NO_CONFIRM_DESTRUCTIVE`). When truthy, the `/clear`
|
||||
* and `/new` confirm step is bypassed and the action runs immediately. Default
|
||||
* off (confirm). Same name, same truthy parsing as Ink.
|
||||
*/
|
||||
export function noConfirmDestructive(env: { readonly [k: string]: string | undefined } = process.env): boolean {
|
||||
return envFlag(env.HERMES_TUI_NO_CONFIRM, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* `HERMES_HEAPDUMP_ON_START` — write a manual heap snapshot at boot (Ink parity).
|
||||
* A diagnostic escape hatch that BYPASSES the diagnostics master switch (you set
|
||||
* it deliberately to capture a baseline). Default off.
|
||||
*/
|
||||
export function heapdumpOnStart(env: { readonly [k: string]: string | undefined } = process.env): boolean {
|
||||
return envFlag(env.HERMES_HEAPDUMP_ON_START, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* `HERMES_TUI_SCROLL_SPEED` (or `CLAUDE_CODE_SCROLL_SPEED` for portability) —
|
||||
* the wheel-scroll speed multiplier (Ink parity, `lib/wheelAccel.ts`
|
||||
* `readScrollSpeedBase`). Default 1 (the engine's native scroll behavior is
|
||||
* untouched), clamped to (0, 20]. Returns `null` when UNSET/garbage so the
|
||||
* caller leaves OpenTUI's native scroll acceleration alone — only an explicit,
|
||||
* in-range value installs a constant-multiplier override.
|
||||
*/
|
||||
export function scrollSpeedMultiplier(env: { readonly [k: string]: string | undefined } = process.env): number | null {
|
||||
const raw = (env.HERMES_TUI_SCROLL_SPEED ?? env.CLAUDE_CODE_SCROLL_SPEED ?? '').trim()
|
||||
if (!raw) return null
|
||||
const n = Number.parseFloat(raw)
|
||||
if (!Number.isFinite(n) || n <= 0) return null
|
||||
return Math.min(n, 20)
|
||||
}
|
||||
|
||||
/**
|
||||
* The diagnostics master switch — `HERMES_TUI_DIAGNOSTICS` (default OFF).
|
||||
*
|
||||
* Gates the developer/profiling surface a regular user should never trip
|
||||
* over: the diagnostic slash commands (`/mem`, `/heapdump`) and the default
|
||||
* for `HERMES_TUI_WINDOW_STATS` (which can still be set individually). It is
|
||||
* an enable switch, not a secret: anyone CAN set it (support flows say
|
||||
* "relaunch with HERMES_TUI_DIAGNOSTICS=1"), it just keeps the day-to-day
|
||||
* surface clean. Read per call so tests (and long-lived processes whose
|
||||
* wrapper mutates env before launch) see the live value.
|
||||
*/
|
||||
export function diagnosticsEnabled(): boolean {
|
||||
return envFlag(process.env.HERMES_TUI_DIAGNOSTICS, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether rich tool-call OUTPUTS are kept — `HERMES_TUI_TOOL_OUTPUTS` (default
|
||||
* ON). OpenTUI's rich tool cards (full result body + raw result/args dicts) are
|
||||
* its differentiator vs Ink, so they stay on for real users. Setting `=off`
|
||||
* drops both the RENDER and the STORE of those bodies (exact Ink parity: Ink
|
||||
* keeps only a short context line and discards the result/args dicts), which is
|
||||
* the biggest memory lever — used by the bench (D8: a fair Ink-vs-OpenTUI
|
||||
* engine-overhead comparison) and the low-mem mode. The redaction-safe
|
||||
* `argsPreview` one-liner, name/duration/error, and file-edit diffs are KEPT
|
||||
* either way (a diff is a high-value surface, not generic "output"). Read per
|
||||
* call so a wrapper that mutates env before launch sees the live value.
|
||||
*/
|
||||
export function toolOutputsEnabled(): boolean {
|
||||
return envFlag(process.env.HERMES_TUI_TOOL_OUTPUTS, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `HERMES_TUI_TOOL_OUTPUT_LINES` (a TUI-only env var — deliberately NOT
|
||||
* a config.yaml knob): how many output lines an expanded tool body shows.
|
||||
* UNSET → Infinity (UNLIMITED — expanded tool output is uncapped by default;
|
||||
* setting the var is how you RESTORE a cap, e.g. `=200`). A positive integer
|
||||
* → that cap. `0` → Infinity too (back-compat: it was the old opt-in
|
||||
* "unlimited" value). Garbage → Infinity (unrecognized ≙ no cap asked for —
|
||||
* the semantic is "cap only when the user asked for one").
|
||||
*/
|
||||
export function envOutputLines(value: string | undefined): number {
|
||||
const v = value?.trim() ?? ''
|
||||
if (!/^\d+$/.test(v)) return Number.POSITIVE_INFINITY
|
||||
const n = Number.parseInt(v, 10)
|
||||
return n === 0 ? Number.POSITIVE_INFINITY : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Default visible-height cap for the composer textarea, in rows (Ink composer
|
||||
* parity — 8 lines, ref feature request #10418). Beyond this the textarea
|
||||
* scrolls INTERNALLY (the native edit buffer keeps the cursor in view).
|
||||
*/
|
||||
export const COMPOSER_MAX_ROWS = 8
|
||||
|
||||
/**
|
||||
* Parse `HERMES_TUI_COMPOSER_ROWS` (a TUI-only env var — deliberately NOT a
|
||||
* config.yaml knob): the composer's visible-height cap before internal scroll
|
||||
* kicks in. A positive integer → that cap; unset / `0` / garbage → the
|
||||
* COMPOSER_MAX_ROWS default.
|
||||
*/
|
||||
export function envComposerRows(value: string | undefined): number {
|
||||
const v = value?.trim() ?? ''
|
||||
if (!/^\d+$/.test(v)) return COMPOSER_MAX_ROWS
|
||||
const n = Number.parseInt(v, 10)
|
||||
return n > 0 ? n : COMPOSER_MAX_ROWS
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether NO line cap applies (unset / `0` / unparseable). When unlimited,
|
||||
* the store prefers the always-full raw `result` over a gateway tail-capped
|
||||
* `result_text` — an "unlimited" view of a tail would still be missing its
|
||||
* head — see store.ts tool.complete. With an explicit finite cap the gateway
|
||||
* tail (+ honest omitted note) is kept: the user asked for a bounded view.
|
||||
*/
|
||||
export function envOutputUnlimited(value: string | undefined): boolean {
|
||||
return envOutputLines(value) === Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's launch directory for `session.create`'s `cwd` param.
|
||||
*
|
||||
* The hermes launcher runs the OpenTUI engine with its process cwd set to the
|
||||
* engine's own package dir, so `process.cwd()` is NOT where the user ran
|
||||
* hermes. The launcher exports the real launch dir as `HERMES_CWD` (and the
|
||||
* gateway's `TERMINAL_CWD`); prefer those. Falls back to `process.cwd()` only
|
||||
* for standalone launches (smokes/dev) where no launcher set them, and returns
|
||||
* `undefined` when even that is empty so the gateway resolves its own default.
|
||||
*/
|
||||
export function launchCwd(env: { readonly [k: string]: string | undefined } = process.env): string | undefined {
|
||||
// First NON-BLANK of the launcher's vars (?? would keep a blank HERMES_CWD
|
||||
// and never reach TERMINAL_CWD).
|
||||
for (const value of [env.HERMES_CWD, env.TERMINAL_CWD]) {
|
||||
const trimmed = (value ?? '').trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
try {
|
||||
const cwd = process.cwd().trim()
|
||||
return cwd || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* fuzzy.ts — pure fuzzy filtering + grouped presentation for picker overlays
|
||||
* (Epic 7 model picker v2; resume-session picker; skills hub). Matching/ranking
|
||||
* is delegated to `fuzzysort` (the library opencode uses in production, see its
|
||||
* dialog-select.tsx) through a thin adapter that preserves this module's API:
|
||||
* call sites pass weighted `FuzzyField[]` haystacks and get back a ranked list.
|
||||
*
|
||||
* Adapter semantics on top of fuzzysort:
|
||||
* - Multi-key scoring à la opencode: each field is a fuzzysort key; the final
|
||||
* score is the weight-multiplied SUM of per-key scores (label conventionally
|
||||
* ×2, opencode's `r[0].score * 2 + r[1].score`), so label hits outrank
|
||||
* equal-quality group/slug hits.
|
||||
* - Multi-term AND (a feature of the old hand-rolled scorer that fuzzysort
|
||||
* lacks natively): the query is whitespace-split and fuzzysort runs once per
|
||||
* term over the progressively-filtered pool — every term must match at least
|
||||
* one field; per-term scores accumulate. Chosen over a joined single needle
|
||||
* because it keeps `anthropic son` / `copilot son` matching ACROSS fields.
|
||||
* - Empty/blank query → all items in catalog order (fuzzysort returns nothing
|
||||
* for an empty needle; the old all-rows behavior is preserved here).
|
||||
* - Equal final scores keep catalog order (fuzzysort's sort is not stable; the
|
||||
* adapter re-sorts with the original index as tie-break).
|
||||
*/
|
||||
import fuzzysort from 'fuzzysort'
|
||||
|
||||
/** One searchable field of an item (e.g. model id ×2, provider slug, lab name). */
|
||||
export interface FuzzyField {
|
||||
text: string
|
||||
/** Score multiplier (default 1). The primary label is conventionally 2. */
|
||||
weight?: number
|
||||
}
|
||||
|
||||
/** Pool entry: the item plus its precomputed fields, catalog position and the
|
||||
* per-term accumulated score. */
|
||||
interface Entry<T> {
|
||||
item: T
|
||||
at: number
|
||||
fields: FuzzyField[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter + rank items by query. Empty query → the items in catalog order;
|
||||
* otherwise matches sorted by score (descending), ties keeping catalog order.
|
||||
* Every whitespace-split term must fuzzy-match at least one field.
|
||||
*/
|
||||
export function fuzzyFilter<T>(query: string, items: readonly T[], fieldsOf: (item: T) => FuzzyField[]): T[] {
|
||||
const terms = query.trim().split(/\s+/).filter(Boolean)
|
||||
if (!terms.length) return [...items]
|
||||
|
||||
let pool: Entry<T>[] = items.map((item, at) => ({ at, fields: fieldsOf(item), item, total: 0 }))
|
||||
// Items may carry different field counts (description/haystacks optional):
|
||||
// one key per field slot, missing slots read as '' (never match).
|
||||
const keyCount = pool.reduce((max, e) => Math.max(max, e.fields.length), 0)
|
||||
const keys = Array.from({ length: keyCount }, (_, i) => (e: Entry<T>) => e.fields[i]?.text ?? '')
|
||||
|
||||
for (const term of terms) {
|
||||
const results = fuzzysort.go(term, pool, {
|
||||
keys,
|
||||
// Weighted sum of per-key scores (unmatched keys score 0). Inclusion is
|
||||
// decided by fuzzysort (≥1 key must match); this only ranks.
|
||||
scoreFn: r => {
|
||||
let sum = 0
|
||||
for (let i = 0; i < r.length; i++) sum += (r[i]?.score ?? 0) * (r.obj.fields[i]?.weight ?? 1)
|
||||
return sum
|
||||
}
|
||||
})
|
||||
if (!results.length) return []
|
||||
pool = results.map(r => {
|
||||
r.obj.total += r.score
|
||||
return r.obj
|
||||
})
|
||||
}
|
||||
|
||||
pool.sort((a, b) => b.total - a.total || a.at - b.at)
|
||||
return pool.map(e => e.item)
|
||||
}
|
||||
|
||||
/** A render row of a grouped picker: a non-selectable group header or an item.
|
||||
* `index` is the item's position in the flat ARROW-TRAVERSAL order; `-1` marks
|
||||
* a non-selectable item row (rendered dimmed, skipped by traversal). */
|
||||
export type PickerRow<T> = { kind: 'header'; label: string } | { kind: 'item'; item: T; index: number }
|
||||
|
||||
/**
|
||||
* Group items for display (group order = first appearance, so a score-sorted
|
||||
* input puts the best group first). Returns the header+item render rows and
|
||||
* the flat selectable list in traversal order — arrows walk `flat` and thus
|
||||
* cross group boundaries seamlessly; headers are never selectable. Items
|
||||
* without a group render headerless (e.g. the skills picker). Items failing
|
||||
* `selectableOf` (picker v2.1: unconfigured-provider hint rows) still RENDER
|
||||
* (index `-1`) but never enter `flat`, so ↑↓ traversal skips them.
|
||||
*/
|
||||
export function buildPickerRows<T>(
|
||||
items: readonly T[],
|
||||
groupOf: (item: T) => string | undefined,
|
||||
selectableOf: (item: T) => boolean = () => true
|
||||
): { rows: PickerRow<T>[]; flat: T[] } {
|
||||
const order: string[] = []
|
||||
const buckets = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const group = groupOf(item) ?? ''
|
||||
let bucket = buckets.get(group)
|
||||
if (!bucket) {
|
||||
bucket = []
|
||||
buckets.set(group, bucket)
|
||||
order.push(group)
|
||||
}
|
||||
bucket.push(item)
|
||||
}
|
||||
const rows: PickerRow<T>[] = []
|
||||
const flat: T[] = []
|
||||
for (const group of order) {
|
||||
if (group) rows.push({ kind: 'header', label: group })
|
||||
for (const item of buckets.get(group) ?? []) {
|
||||
if (selectableOf(item)) {
|
||||
rows.push({ index: flat.length, item, kind: 'item' })
|
||||
flat.push(item)
|
||||
} else {
|
||||
rows.push({ index: -1, item, kind: 'item' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { flat, rows }
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice rows to a visible window of at most `cap` rows that keeps the selected
|
||||
* item in view (centered when possible). `above`/`below` are the hidden row
|
||||
* counts for the ↑/↓ "more" indicators.
|
||||
*/
|
||||
export function visibleRows<T>(
|
||||
rows: readonly PickerRow<T>[],
|
||||
selected: number,
|
||||
cap: number
|
||||
): { rows: PickerRow<T>[]; above: number; below: number } {
|
||||
if (rows.length <= cap) return { above: 0, below: 0, rows: [...rows] }
|
||||
const selRow = rows.findIndex(r => r.kind === 'item' && r.index === selected)
|
||||
const anchor = selRow === -1 ? 0 : selRow
|
||||
const start = Math.max(0, Math.min(anchor - Math.floor(cap / 2), rows.length - cap))
|
||||
return { above: start, below: rows.length - (start + cap), rows: rows.slice(start, start + cap) }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Pure recovery-budget policy for the gateway exit handler (LOGIC side — no
|
||||
* Effect, no refs, no UI). Ported from Ink's `ui-tui/src/app/gatewayRecovery.ts`
|
||||
* and EXTENDED with opencode-style exponential backoff.
|
||||
*
|
||||
* A gateway that crash-loops on startup must not let the TUI spawn-storm, so
|
||||
* respawn+resume attempts are capped to GATEWAY_RECOVERY_LIMIT within a sliding
|
||||
* GATEWAY_RECOVERY_WINDOW_MS; past the budget the app falls back to the inert
|
||||
* "gateway exited" state. Kept pure (no refs/UI) so the bound — including the
|
||||
* crash-loop case — is unit-testable.
|
||||
*/
|
||||
export const GATEWAY_RECOVERY_LIMIT = 3
|
||||
export const GATEWAY_RECOVERY_WINDOW_MS = 60_000
|
||||
|
||||
export interface RecoveryPlan {
|
||||
/** Attempt timestamps to persist (the pruned window, plus `now` iff recovering). */
|
||||
attempts: number[]
|
||||
recover: boolean
|
||||
/**
|
||||
* Session to resume — the live sid, or the not-yet-consumed recovery target
|
||||
* when the live sid was already cleared by a prior exit.
|
||||
*/
|
||||
sid: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to respawn+resume after a gateway death. `liveSid` is the
|
||||
* current session (nulled on the first exit); `recoverSid` is a pending
|
||||
* recovery target carried across a respawn that died before gateway.ready —
|
||||
* so a startup crash-loop keeps retrying the same session up to the budget
|
||||
* instead of stranding it after one attempt.
|
||||
*/
|
||||
export function planGatewayRecovery(
|
||||
liveSid: null | string,
|
||||
recoverSid: null | string,
|
||||
attempts: number[],
|
||||
now: number
|
||||
): RecoveryPlan {
|
||||
const sid = liveSid ?? recoverSid
|
||||
const recent = attempts.filter(t => now - t < GATEWAY_RECOVERY_WINDOW_MS)
|
||||
const recover = Boolean(sid) && recent.length < GATEWAY_RECOVERY_LIMIT
|
||||
|
||||
return { attempts: recover ? [...recent, now] : recent, recover, sid }
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff between respawn attempts (opencode-style): 1s, 2s, 4s, …
|
||||
* capped at 30s. `attempt` is 1-based (the first respawn waits 1s).
|
||||
*/
|
||||
export function backoffMs(attempt: number): number {
|
||||
return Math.min(1000 * 2 ** Math.max(0, attempt - 1), 30_000)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Prompt history (item 6) — the SOLID side, plain TS. Up/Down cycle through the
|
||||
* prompts you've sent, scoped PER DIRECTORY: launching Hermes again in the same
|
||||
* project dir reuses that dir's prior prompts (the "bleed for the same dir" the
|
||||
* user asked for), while a session in a different dir keeps its own list.
|
||||
*
|
||||
* `createPromptHistory` is pure + injectable (initial entries + a `persist`
|
||||
* sink) so the cursor logic is unit-tested with no filesystem. The real wiring
|
||||
* uses `loadDirHistory(cwd)` / `dirHistoryPersister(cwd)` to read/append a
|
||||
* per-dir JSONL file under `$HERMES_HOME/tui-history/<hash>.jsonl` (one
|
||||
* JSON-encoded prompt per line, multiline-safe; opencode's prompt-history.jsonl
|
||||
* model, Ink's ~/.hermes/.hermes_history idea, scoped by dir).
|
||||
*/
|
||||
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const DEFAULT_MAX = 200
|
||||
|
||||
export interface PromptHistoryOptions {
|
||||
/** Entries already on disk for this dir (oldest → newest). */
|
||||
initial?: string[]
|
||||
/** Persist a newly pushed prompt (real use: append to the per-dir file). */
|
||||
persist?: (text: string) => void
|
||||
/** Cap on retained entries (oldest dropped). */
|
||||
max?: number
|
||||
}
|
||||
|
||||
export interface PromptHistory {
|
||||
/** All cycleable entries (oldest → newest) — loaded prev-session + this session. */
|
||||
entries: () => string[]
|
||||
/** Record a submitted prompt (skips a consecutive duplicate) and reset the cursor. */
|
||||
push: (text: string) => void
|
||||
/** Cycle to the OLDER entry (Up). Stashes `currentInput` as the draft on the first step. */
|
||||
prev: (currentInput: string) => string | null
|
||||
/** Cycle to the NEWER entry (Down); returns the stashed draft at the bottom. */
|
||||
next: () => string | null
|
||||
/** Reset the cursor to the live draft (call on any edit). */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export function createPromptHistory(opts: PromptHistoryOptions = {}): PromptHistory {
|
||||
const entries = [...(opts.initial ?? [])]
|
||||
const max = opts.max ?? DEFAULT_MAX
|
||||
// `idx === entries.length` means "at the live draft" (past the newest entry).
|
||||
let idx = entries.length
|
||||
let draft = ''
|
||||
|
||||
return {
|
||||
entries: () => entries.slice(),
|
||||
push(text) {
|
||||
if (!text.trim()) return
|
||||
if (entries[entries.length - 1] !== text) {
|
||||
entries.push(text)
|
||||
if (entries.length > max) entries.shift()
|
||||
opts.persist?.(text)
|
||||
}
|
||||
idx = entries.length
|
||||
draft = ''
|
||||
},
|
||||
prev(currentInput) {
|
||||
if (entries.length === 0) return null
|
||||
if (idx === entries.length) draft = currentInput // leaving the bottom — stash the draft
|
||||
if (idx > 0) idx--
|
||||
return entries[idx] ?? null
|
||||
},
|
||||
next() {
|
||||
if (idx >= entries.length) return null
|
||||
idx++
|
||||
return idx === entries.length ? draft : (entries[idx] ?? null)
|
||||
},
|
||||
reset() {
|
||||
idx = entries.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── per-directory file persistence (best-effort; never throws) ──────────
|
||||
|
||||
function hermesHome(): string {
|
||||
return process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
|
||||
}
|
||||
|
||||
/** The history file for a given working directory (keyed by a hash of the abs path). */
|
||||
function dirHistoryPath(cwd: string): string {
|
||||
const key = createHash('sha1').update(cwd).digest('hex').slice(0, 16)
|
||||
return join(hermesHome(), 'tui-history', `${key}.jsonl`)
|
||||
}
|
||||
|
||||
/** Load a directory's prior prompts (oldest → newest); [] if none / unreadable. */
|
||||
export function loadDirHistory(cwd: string, max = DEFAULT_MAX): string[] {
|
||||
try {
|
||||
const raw = readFileSync(dirHistoryPath(cwd), 'utf8')
|
||||
const out: string[] = []
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const v: unknown = JSON.parse(line)
|
||||
if (typeof v === 'string') out.push(v)
|
||||
} catch {
|
||||
// skip a corrupt line — never let it break loading
|
||||
}
|
||||
}
|
||||
return out.length > max ? out.slice(out.length - max) : out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** A persister that appends each pushed prompt to the dir's JSONL file (best-effort). */
|
||||
export function dirHistoryPersister(cwd: string): (text: string) => void {
|
||||
const path = dirHistoryPath(cwd)
|
||||
return text => {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
appendFileSync(path, JSON.stringify(text) + '\n', 'utf8')
|
||||
} catch {
|
||||
// history persistence is non-essential — a write failure must not disrupt the turn
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Fence-aware LaTeX→Unicode span converter. Runs on the raw markdown string
|
||||
* BEFORE it reaches the native `<markdown>` renderable (the one seam in
|
||||
* view/markdown.tsx), so the native parser only ever sees already-converted
|
||||
* unicode text. Tier-A: text-only — no styled spans, no accent color on math
|
||||
* (that needs renderNode hooks into MarkdownRenderable; deferred).
|
||||
*
|
||||
* Span detection ports the Ink tokenizer's EXACT rules (ui-tui/src/components/
|
||||
* markdown.tsx — keep in sync):
|
||||
*
|
||||
* • inline `$…$` — INLINE_RE group 17:
|
||||
* (?<!\$)\$([^\s$](?:[^$\n]*?[^\s$])?)\$(?!\$)
|
||||
* content starts AND ends with a non-space-non-`$`, contains no `$` or
|
||||
* newline. This is the currency guard: in `I paid $5 and $10` the closing
|
||||
* `$` is preceded by a space, so nothing matches and the prose survives.
|
||||
* • inline `\(…\)` — INLINE_RE group 18: `\\\(([^\n]+?)\\\)` (single line).
|
||||
* • display `$$…$$` / `\[…\]` — MATH_BLOCK_OPEN_RE: opener only at the start
|
||||
* of a (whitespace-trimmed) line; closes on the same line (`$$x$$`) or on a
|
||||
* later line ENDING with the closer. No closer anywhere → the line passes
|
||||
* through verbatim (Ink renders it as a plain paragraph). That rule is what
|
||||
* makes streaming safe for free: an unclosed `$$`/`$` mid-stream stays
|
||||
* verbatim and converts exactly once, when the closing delimiter arrives
|
||||
* (the whole text re-feeds per delta).
|
||||
*
|
||||
* Because the markdown parser hasn't run yet, fence / inline-code state is
|
||||
* tracked here:
|
||||
* • fenced blocks: ``` or ~~~ runs (3+, any info string) open; a line that is
|
||||
* only a run of the SAME character, at least as long, closes (CommonMark).
|
||||
* Everything inside, including the fence lines, passes through untouched.
|
||||
* • inline code: per-line backtick scan — a run of N backticks opens a span
|
||||
* closed by the next run of EXACTLY N backticks on the same line
|
||||
* (CommonMark rule); unmatched runs are literal text. Multi-line inline
|
||||
* code spans are NOT supported (the Ink tokenizer was per-line too).
|
||||
*
|
||||
* Known, documented deviations from full markdown awareness (both rare, both
|
||||
* shared with or narrower than the Ink renderer's behavior):
|
||||
* • a paired `$…$` inside a link destination (`[x](http://a$b$c)`) converts;
|
||||
* Ink's tokenizer matched the link first.
|
||||
* • 4-space-indented code blocks are not tracked (fences only).
|
||||
*
|
||||
* `\boxed{…}` sentinels (U+0001/U+0002 from texToUnicode) are STRIPPED to the
|
||||
* inner text — injecting a styled span into the native renderable needs
|
||||
* renderer hooks; deferred with the rest of tier-B.
|
||||
*
|
||||
* Perf: this runs over the FULL text on every streaming delta. Early-exit
|
||||
* fast path returns the same string reference when no `$` / `\(` / `\[`
|
||||
* appears at all, and when a scan converts nothing the original reference is
|
||||
* returned too (so the renderable's content prop stays identity-stable).
|
||||
*/
|
||||
import { BOX_RE, texToUnicode } from './mathUnicode.ts'
|
||||
|
||||
const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})/
|
||||
const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/
|
||||
|
||||
// Display math openers/closers — ported verbatim from Ink's markdown.tsx.
|
||||
const MATH_BLOCK_OPEN_RE = /^\s*(\$\$|\\\[)(.*)$/
|
||||
const MATH_BLOCK_CLOSE_DOLLAR_RE = /^(.*?)\$\$\s*$/
|
||||
const MATH_BLOCK_CLOSE_BRACKET_RE = /^(.*?)\\\]\s*$/
|
||||
|
||||
// Ink INLINE_RE group 17 / 18, anchored (sticky). The `(?<!\$)` lookbehind is
|
||||
// checked by the caller (prev char), everything else is byte-for-byte Ink's.
|
||||
const INLINE_DOLLAR_RE = /\$([^\s$](?:[^$\n]*?[^\s$])?)\$(?!\$)/y
|
||||
const INLINE_PAREN_RE = /\\\(([^\n]+?)\\\)/y
|
||||
|
||||
/** texToUnicode + strip the \boxed highlight sentinels down to plain text. */
|
||||
const toUnicode = (tex: string): string => texToUnicode(tex).replace(BOX_RE, '$1')
|
||||
|
||||
/** Index of the next run of EXACTLY `len` backticks at/after `from`, or -1. */
|
||||
const findBacktickClose = (line: string, from: number, len: number): number => {
|
||||
let i = from
|
||||
while (i < line.length) {
|
||||
if (line[i] !== '`') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
let j = i + 1
|
||||
|
||||
while (j < line.length && line[j] === '`') j++
|
||||
|
||||
if (j - i === len) {
|
||||
return i
|
||||
}
|
||||
|
||||
i = j
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// Convert inline `$…$` / `\(…\)` spans in one prose line, skipping inline
|
||||
// code spans. Returns the SAME string reference when nothing converted.
|
||||
const convertInline = (line: string): string => {
|
||||
let out = ''
|
||||
let i = 0
|
||||
let changed = false
|
||||
|
||||
while (i < line.length) {
|
||||
const ch = line[i]
|
||||
|
||||
if (ch === '`') {
|
||||
let j = i + 1
|
||||
|
||||
while (j < line.length && line[j] === '`') j++
|
||||
|
||||
const close = findBacktickClose(line, j, j - i)
|
||||
|
||||
if (close >= 0) {
|
||||
out += line.slice(i, close + (j - i))
|
||||
i = close + (j - i)
|
||||
} else {
|
||||
out += line.slice(i, j)
|
||||
i = j
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '$' && line[i - 1] !== '$') {
|
||||
INLINE_DOLLAR_RE.lastIndex = i
|
||||
const m = INLINE_DOLLAR_RE.exec(line)
|
||||
|
||||
if (m) {
|
||||
out += toUnicode(m[1] ?? '')
|
||||
i = INLINE_DOLLAR_RE.lastIndex
|
||||
changed = true
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (ch === '\\' && line[i + 1] === '(') {
|
||||
INLINE_PAREN_RE.lastIndex = i
|
||||
const m = INLINE_PAREN_RE.exec(line)
|
||||
|
||||
if (m) {
|
||||
out += toUnicode(m[1] ?? '')
|
||||
i = INLINE_PAREN_RE.lastIndex
|
||||
changed = true
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
|
||||
return changed ? out : line
|
||||
}
|
||||
|
||||
export function preprocessMath(markdown: string, _opts?: { streaming?: boolean | undefined }): string {
|
||||
// Fast path — REQUIRED, this runs on every streaming delta. No math trigger
|
||||
// characters anywhere → hand back the exact same string (identity).
|
||||
if (!markdown.includes('$') && !markdown.includes('\\(') && !markdown.includes('\\[')) {
|
||||
return markdown
|
||||
}
|
||||
|
||||
const lines = markdown.split('\n')
|
||||
const out: string[] = []
|
||||
let changed = false
|
||||
let fence: { char: string; len: number } | null = null
|
||||
let i = 0
|
||||
|
||||
// Emit a converted display block as its own paragraph: blank-line separated
|
||||
// from surrounding prose (only where a separator is actually missing).
|
||||
const pushDisplay = (block: string[], nextIdx: number) => {
|
||||
if (out.length > 0 && out[out.length - 1]?.trim()) {
|
||||
out.push('')
|
||||
}
|
||||
|
||||
out.push(...block)
|
||||
|
||||
if (nextIdx < lines.length && lines[nextIdx]?.trim()) {
|
||||
out.push('')
|
||||
}
|
||||
|
||||
changed = true
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i] ?? ''
|
||||
|
||||
if (fence) {
|
||||
out.push(line)
|
||||
const close = line.match(FENCE_CLOSE_RE)?.[1]
|
||||
|
||||
if (close && close.charAt(0) === fence.char && close.length >= fence.len) {
|
||||
fence = null
|
||||
}
|
||||
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const open = line.match(FENCE_OPEN_RE)?.[1]
|
||||
|
||||
if (open) {
|
||||
fence = { char: open.charAt(0), len: open.length }
|
||||
out.push(line)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const mathOpen = line.match(MATH_BLOCK_OPEN_RE)
|
||||
|
||||
if (mathOpen) {
|
||||
const closeRe = mathOpen[1] === '$$' ? MATH_BLOCK_CLOSE_DOLLAR_RE : MATH_BLOCK_CLOSE_BRACKET_RE
|
||||
const headRest = mathOpen[2] ?? ''
|
||||
|
||||
// Single-line block: `$$x + y = z$$` or `\[x\]`.
|
||||
const sameLineClose = headRest.match(closeRe)
|
||||
|
||||
if (sameLineClose) {
|
||||
const inner = (sameLineClose[1] ?? '').trim()
|
||||
pushDisplay(inner ? [toUnicode(inner)] : [], i + 1)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Multi-line block: scan ahead for a real closer before committing. If
|
||||
// none exists in the rest of the (possibly still-streaming) document,
|
||||
// the line stays verbatim — Ink's paragraph fallback.
|
||||
let closeIdx = -1
|
||||
let closeTail = ''
|
||||
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const m = (lines[j] ?? '').match(closeRe)
|
||||
|
||||
if (m) {
|
||||
closeIdx = j
|
||||
closeTail = m[1] ?? ''
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (closeIdx >= 0) {
|
||||
const block: string[] = []
|
||||
|
||||
if (headRest.trim()) {
|
||||
block.push(headRest)
|
||||
}
|
||||
|
||||
for (let j = i + 1; j < closeIdx; j++) {
|
||||
block.push(lines[j] ?? '')
|
||||
}
|
||||
|
||||
const tail = closeTail.trimEnd()
|
||||
|
||||
if (tail.trim()) {
|
||||
block.push(tail)
|
||||
}
|
||||
|
||||
pushDisplay(
|
||||
block.map(l => toUnicode(l)),
|
||||
closeIdx + 1
|
||||
)
|
||||
i = closeIdx + 1
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const converted = convertInline(line)
|
||||
|
||||
if (converted !== line) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
out.push(converted)
|
||||
i++
|
||||
}
|
||||
|
||||
return changed ? out.join('\n') : markdown
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
// Best-effort LaTeX → Unicode for inline / display math (ported verbatim from
|
||||
// ui-tui/src/lib/mathUnicode.ts — keep the two in sync). The terminal can't
|
||||
// typeset LaTeX, but Unicode covers
|
||||
// most of what models actually emit: Greek letters, blackboard / fraktur /
|
||||
// calligraphic capitals, set theory + logic operators, common arrows,
|
||||
// sub/superscripts, and `\frac{a}{b}` collapsed to `a/b`.
|
||||
//
|
||||
// Design rules:
|
||||
// • Pure regex pipeline. Anything we don't recognise is preserved
|
||||
// verbatim (so a `\foo{bar}` we've never heard of still survives).
|
||||
// A real LaTeX parser would be more correct but throws on partial
|
||||
// input — terminal users would rather see the raw command than a
|
||||
// parse-error placeholder.
|
||||
// • Longest-match-first ordering on commands so `\le` doesn't shadow
|
||||
// `\leq`, `\sub` doesn't shadow `\subseteq`, etc.
|
||||
// • Word-boundary lookahead `(?![A-Za-z])` after each command so
|
||||
// `\pix` (made-up command) doesn't get partially substituted as `π`.
|
||||
// • `\mathbb{X}`, `\mathcal{X}`, `\mathfrak{X}` only handle a single
|
||||
// letter argument — multi-letter `\mathbb{NN}` is rare and would
|
||||
// need a real parser to do correctly.
|
||||
// • Sub/super scripts only convert if EVERY character has a Unicode
|
||||
// equivalent. Mixed content like `^{n+1}` falls back to the raw
|
||||
// LaTeX so we don't emit `ⁿ+¹` (which has no `+` superscript glyph
|
||||
// in some fonts and reads worse than the source).
|
||||
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
// Greek lowercase
|
||||
'\\alpha': 'α',
|
||||
'\\beta': 'β',
|
||||
'\\gamma': 'γ',
|
||||
'\\delta': 'δ',
|
||||
'\\epsilon': 'ε',
|
||||
'\\varepsilon': 'ε',
|
||||
'\\zeta': 'ζ',
|
||||
'\\eta': 'η',
|
||||
'\\theta': 'θ',
|
||||
'\\vartheta': 'ϑ',
|
||||
'\\iota': 'ι',
|
||||
'\\kappa': 'κ',
|
||||
'\\lambda': 'λ',
|
||||
'\\mu': 'μ',
|
||||
'\\nu': 'ν',
|
||||
'\\xi': 'ξ',
|
||||
'\\pi': 'π',
|
||||
'\\varpi': 'ϖ',
|
||||
'\\rho': 'ρ',
|
||||
'\\varrho': 'ϱ',
|
||||
'\\sigma': 'σ',
|
||||
'\\varsigma': 'ς',
|
||||
'\\tau': 'τ',
|
||||
'\\upsilon': 'υ',
|
||||
'\\phi': 'φ',
|
||||
'\\varphi': 'φ',
|
||||
'\\chi': 'χ',
|
||||
'\\psi': 'ψ',
|
||||
'\\omega': 'ω',
|
||||
|
||||
// Greek uppercase
|
||||
'\\Gamma': 'Γ',
|
||||
'\\Delta': 'Δ',
|
||||
'\\Theta': 'Θ',
|
||||
'\\Lambda': 'Λ',
|
||||
'\\Xi': 'Ξ',
|
||||
'\\Pi': 'Π',
|
||||
'\\Sigma': 'Σ',
|
||||
'\\Upsilon': 'Υ',
|
||||
'\\Phi': 'Φ',
|
||||
'\\Psi': 'Ψ',
|
||||
'\\Omega': 'Ω',
|
||||
|
||||
// Big operators
|
||||
'\\sum': '∑',
|
||||
'\\prod': '∏',
|
||||
'\\coprod': '∐',
|
||||
'\\int': '∫',
|
||||
'\\iint': '∬',
|
||||
'\\iiint': '∭',
|
||||
'\\oint': '∮',
|
||||
'\\bigcup': '⋃',
|
||||
'\\bigcap': '⋂',
|
||||
'\\bigvee': '⋁',
|
||||
'\\bigwedge': '⋀',
|
||||
'\\bigoplus': '⨁',
|
||||
'\\bigotimes': '⨂',
|
||||
|
||||
// Calculus
|
||||
'\\partial': '∂',
|
||||
'\\nabla': '∇',
|
||||
'\\sqrt': '√',
|
||||
|
||||
// Sets
|
||||
'\\emptyset': '∅',
|
||||
'\\varnothing': '∅',
|
||||
'\\infty': '∞',
|
||||
'\\in': '∈',
|
||||
'\\notin': '∉',
|
||||
'\\ni': '∋',
|
||||
'\\subset': '⊂',
|
||||
'\\supset': '⊃',
|
||||
'\\subseteq': '⊆',
|
||||
'\\supseteq': '⊇',
|
||||
'\\subsetneq': '⊊',
|
||||
'\\supsetneq': '⊋',
|
||||
'\\cup': '∪',
|
||||
'\\cap': '∩',
|
||||
'\\setminus': '∖',
|
||||
'\\complement': '∁',
|
||||
|
||||
// Logic
|
||||
'\\forall': '∀',
|
||||
'\\exists': '∃',
|
||||
'\\nexists': '∄',
|
||||
'\\land': '∧',
|
||||
'\\lor': '∨',
|
||||
'\\lnot': '¬',
|
||||
'\\neg': '¬',
|
||||
'\\therefore': '∴',
|
||||
'\\because': '∵',
|
||||
|
||||
// Relations
|
||||
'\\le': '≤',
|
||||
'\\leq': '≤',
|
||||
'\\ge': '≥',
|
||||
'\\geq': '≥',
|
||||
'\\ne': '≠',
|
||||
'\\neq': '≠',
|
||||
'\\ll': '≪',
|
||||
'\\gg': '≫',
|
||||
'\\approx': '≈',
|
||||
'\\equiv': '≡',
|
||||
'\\cong': '≅',
|
||||
'\\sim': '∼',
|
||||
'\\simeq': '≃',
|
||||
'\\propto': '∝',
|
||||
'\\perp': '⊥',
|
||||
'\\parallel': '∥',
|
||||
'\\models': '⊨',
|
||||
'\\vdash': '⊢',
|
||||
'\\mid': '∣',
|
||||
'\\nmid': '∤',
|
||||
'\\divides': '∣',
|
||||
|
||||
// Common standalone glyphs
|
||||
'\\blacksquare': '■',
|
||||
'\\square': '□',
|
||||
'\\Box': '□',
|
||||
'\\qed': '∎',
|
||||
'\\bigstar': '★',
|
||||
|
||||
// Modular arithmetic — the `\pmod{p}` form (with arg) is handled below;
|
||||
// the bare `\bmod` / `\mod` commands are simple text substitutions.
|
||||
'\\bmod': 'mod',
|
||||
'\\mod': 'mod',
|
||||
|
||||
// Brackets / fences (named delimiter commands; the `\left\X` / `\right\X`
|
||||
// unwrapping below leaves these behind for the symbol pass to resolve).
|
||||
'\\langle': '⟨',
|
||||
'\\rangle': '⟩',
|
||||
'\\lceil': '⌈',
|
||||
'\\rceil': '⌉',
|
||||
'\\lfloor': '⌊',
|
||||
'\\rfloor': '⌋',
|
||||
'\\|': '‖',
|
||||
|
||||
// Arrows
|
||||
'\\to': '→',
|
||||
'\\rightarrow': '→',
|
||||
'\\leftarrow': '←',
|
||||
'\\leftrightarrow': '↔',
|
||||
'\\Rightarrow': '⇒',
|
||||
'\\Leftarrow': '⇐',
|
||||
'\\Leftrightarrow': '⇔',
|
||||
'\\implies': '⟹',
|
||||
'\\impliedby': '⟸',
|
||||
'\\iff': '⟺',
|
||||
'\\mapsto': '↦',
|
||||
'\\hookrightarrow': '↪',
|
||||
'\\hookleftarrow': '↩',
|
||||
'\\uparrow': '↑',
|
||||
'\\downarrow': '↓',
|
||||
'\\updownarrow': '↕',
|
||||
|
||||
// Binary operators
|
||||
'\\cdot': '⋅',
|
||||
'\\cdots': '⋯',
|
||||
'\\ldots': '…',
|
||||
'\\dots': '…',
|
||||
'\\dotsb': '…',
|
||||
'\\dotsc': '…',
|
||||
'\\vdots': '⋮',
|
||||
'\\ddots': '⋱',
|
||||
'\\times': '×',
|
||||
'\\div': '÷',
|
||||
'\\pm': '±',
|
||||
'\\mp': '∓',
|
||||
'\\circ': '∘',
|
||||
'\\bullet': '•',
|
||||
'\\star': '⋆',
|
||||
'\\ast': '∗',
|
||||
'\\oplus': '⊕',
|
||||
'\\ominus': '⊖',
|
||||
'\\otimes': '⊗',
|
||||
'\\odot': '⊙',
|
||||
'\\diamond': '⋄',
|
||||
'\\angle': '∠',
|
||||
'\\triangle': '△',
|
||||
|
||||
// Spacing — collapse to varying widths of regular space
|
||||
'\\,': ' ',
|
||||
'\\;': ' ',
|
||||
'\\:': ' ',
|
||||
'\\!': '',
|
||||
'\\ ': ' ',
|
||||
'\\quad': ' ',
|
||||
'\\qquad': ' ',
|
||||
|
||||
// Functions (LaTeX renders these in roman; we just keep the name)
|
||||
'\\sin': 'sin',
|
||||
'\\cos': 'cos',
|
||||
'\\tan': 'tan',
|
||||
'\\cot': 'cot',
|
||||
'\\sec': 'sec',
|
||||
'\\csc': 'csc',
|
||||
'\\arcsin': 'arcsin',
|
||||
'\\arccos': 'arccos',
|
||||
'\\arctan': 'arctan',
|
||||
'\\sinh': 'sinh',
|
||||
'\\cosh': 'cosh',
|
||||
'\\tanh': 'tanh',
|
||||
'\\log': 'log',
|
||||
'\\ln': 'ln',
|
||||
'\\exp': 'exp',
|
||||
'\\det': 'det',
|
||||
'\\dim': 'dim',
|
||||
'\\ker': 'ker',
|
||||
'\\lim': 'lim',
|
||||
'\\liminf': 'liminf',
|
||||
'\\limsup': 'limsup',
|
||||
'\\sup': 'sup',
|
||||
'\\inf': 'inf',
|
||||
'\\max': 'max',
|
||||
'\\min': 'min',
|
||||
'\\arg': 'arg',
|
||||
'\\gcd': 'gcd',
|
||||
|
||||
// Escaped literals — model occasionally emits these for display
|
||||
'\\&': '&',
|
||||
'\\%': '%',
|
||||
'\\$': '$',
|
||||
'\\#': '#',
|
||||
'\\_': '_',
|
||||
'\\{': '{',
|
||||
'\\}': '}'
|
||||
}
|
||||
|
||||
const BB: Record<string, string> = {
|
||||
A: '𝔸',
|
||||
B: '𝔹',
|
||||
C: 'ℂ',
|
||||
D: '𝔻',
|
||||
E: '𝔼',
|
||||
F: '𝔽',
|
||||
G: '𝔾',
|
||||
H: 'ℍ',
|
||||
I: '𝕀',
|
||||
J: '𝕁',
|
||||
K: '𝕂',
|
||||
L: '𝕃',
|
||||
M: '𝕄',
|
||||
N: 'ℕ',
|
||||
O: '𝕆',
|
||||
P: 'ℙ',
|
||||
Q: 'ℚ',
|
||||
R: 'ℝ',
|
||||
S: '𝕊',
|
||||
T: '𝕋',
|
||||
U: '𝕌',
|
||||
V: '𝕍',
|
||||
W: '𝕎',
|
||||
X: '𝕏',
|
||||
Y: '𝕐',
|
||||
Z: 'ℤ'
|
||||
}
|
||||
|
||||
const CAL: Record<string, string> = {
|
||||
A: '𝒜',
|
||||
B: 'ℬ',
|
||||
C: '𝒞',
|
||||
D: '𝒟',
|
||||
E: 'ℰ',
|
||||
F: 'ℱ',
|
||||
G: '𝒢',
|
||||
H: 'ℋ',
|
||||
I: 'ℐ',
|
||||
J: '𝒥',
|
||||
K: '𝒦',
|
||||
L: 'ℒ',
|
||||
M: 'ℳ',
|
||||
N: '𝒩',
|
||||
O: '𝒪',
|
||||
P: '𝒫',
|
||||
Q: '𝒬',
|
||||
R: 'ℛ',
|
||||
S: '𝒮',
|
||||
T: '𝒯',
|
||||
U: '𝒰',
|
||||
V: '𝒱',
|
||||
W: '𝒲',
|
||||
X: '𝒳',
|
||||
Y: '𝒴',
|
||||
Z: '𝒵'
|
||||
}
|
||||
|
||||
const FRAK: Record<string, string> = {
|
||||
A: '𝔄',
|
||||
B: '𝔅',
|
||||
C: 'ℭ',
|
||||
D: '𝔇',
|
||||
E: '𝔈',
|
||||
F: '𝔉',
|
||||
G: '𝔊',
|
||||
H: 'ℌ',
|
||||
I: 'ℑ',
|
||||
J: '𝔍',
|
||||
K: '𝔎',
|
||||
L: '𝔏',
|
||||
M: '𝔐',
|
||||
N: '𝔑',
|
||||
O: '𝔒',
|
||||
P: '𝔓',
|
||||
Q: '𝔔',
|
||||
R: 'ℜ',
|
||||
S: '𝔖',
|
||||
T: '𝔗',
|
||||
U: '𝔘',
|
||||
V: '𝔙',
|
||||
W: '𝔚',
|
||||
X: '𝔛',
|
||||
Y: '𝔜',
|
||||
Z: 'ℨ'
|
||||
}
|
||||
|
||||
const SUPERSCRIPT: Record<string, string> = {
|
||||
'0': '⁰',
|
||||
'1': '¹',
|
||||
'2': '²',
|
||||
'3': '³',
|
||||
'4': '⁴',
|
||||
'5': '⁵',
|
||||
'6': '⁶',
|
||||
'7': '⁷',
|
||||
'8': '⁸',
|
||||
'9': '⁹',
|
||||
'+': '⁺',
|
||||
'-': '⁻',
|
||||
'=': '⁼',
|
||||
'(': '⁽',
|
||||
')': '⁾',
|
||||
a: 'ᵃ',
|
||||
b: 'ᵇ',
|
||||
c: 'ᶜ',
|
||||
d: 'ᵈ',
|
||||
e: 'ᵉ',
|
||||
f: 'ᶠ',
|
||||
g: 'ᵍ',
|
||||
h: 'ʰ',
|
||||
i: 'ⁱ',
|
||||
j: 'ʲ',
|
||||
k: 'ᵏ',
|
||||
l: 'ˡ',
|
||||
m: 'ᵐ',
|
||||
n: 'ⁿ',
|
||||
o: 'ᵒ',
|
||||
p: 'ᵖ',
|
||||
r: 'ʳ',
|
||||
s: 'ˢ',
|
||||
t: 'ᵗ',
|
||||
u: 'ᵘ',
|
||||
v: 'ᵛ',
|
||||
w: 'ʷ',
|
||||
x: 'ˣ',
|
||||
y: 'ʸ',
|
||||
z: 'ᶻ'
|
||||
}
|
||||
|
||||
const SUBSCRIPT: Record<string, string> = {
|
||||
'0': '₀',
|
||||
'1': '₁',
|
||||
'2': '₂',
|
||||
'3': '₃',
|
||||
'4': '₄',
|
||||
'5': '₅',
|
||||
'6': '₆',
|
||||
'7': '₇',
|
||||
'8': '₈',
|
||||
'9': '₉',
|
||||
'+': '₊',
|
||||
'-': '₋',
|
||||
'=': '₌',
|
||||
'(': '₍',
|
||||
')': '₎',
|
||||
a: 'ₐ',
|
||||
e: 'ₑ',
|
||||
h: 'ₕ',
|
||||
i: 'ᵢ',
|
||||
j: 'ⱼ',
|
||||
k: 'ₖ',
|
||||
l: 'ₗ',
|
||||
m: 'ₘ',
|
||||
n: 'ₙ',
|
||||
o: 'ₒ',
|
||||
p: 'ₚ',
|
||||
r: 'ᵣ',
|
||||
s: 'ₛ',
|
||||
t: 'ₜ',
|
||||
u: 'ᵤ',
|
||||
v: 'ᵥ',
|
||||
x: 'ₓ'
|
||||
}
|
||||
|
||||
// Sentinel control characters used to mark `\boxed` / `\fbox` regions in
|
||||
// the converted output. The renderer splits on these to apply a highlight
|
||||
// style; consumers that don't want highlighting can strip them with the
|
||||
// exported `BOX_RE` below.
|
||||
export const BOX_OPEN = '\u0001'
|
||||
export const BOX_CLOSE = '\u0002'
|
||||
// eslint-disable-next-line no-control-regex
|
||||
export const BOX_RE = /\u0001([^\u0001\u0002]*)\u0002/g
|
||||
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
// Pre-compile two symbol regexes: one for letter-ending commands (`\pi`,
|
||||
// `\sum`) which need a `(?![A-Za-z])` lookahead so they don't partially
|
||||
// match `\pix` or `\summa`, and one for punctuation-ending commands
|
||||
// (`\{`, `\,`, `\|`) which must NOT have the lookahead — otherwise
|
||||
// `\{p` would refuse to substitute because `p` is a letter.
|
||||
//
|
||||
// Longest commands first inside each group so `\leq` beats `\le`.
|
||||
const splitByEnding = (keys: string[]) => {
|
||||
const letter: string[] = []
|
||||
const punct: string[] = []
|
||||
|
||||
for (const k of keys) {
|
||||
if (/[A-Za-z]$/.test(k)) {
|
||||
letter.push(k)
|
||||
} else {
|
||||
punct.push(k)
|
||||
}
|
||||
}
|
||||
|
||||
return { letter, punct }
|
||||
}
|
||||
|
||||
const buildAlt = (cmds: string[]) =>
|
||||
cmds
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(escapeRe)
|
||||
.join('|')
|
||||
|
||||
const { letter: LETTER_CMDS, punct: PUNCT_CMDS } = splitByEnding(Object.keys(SYMBOLS))
|
||||
|
||||
const SYMBOL_LETTER_RE = new RegExp('(?:' + buildAlt(LETTER_CMDS) + ')(?![A-Za-z])', 'g')
|
||||
const SYMBOL_PUNCT_RE = new RegExp('(?:' + buildAlt(PUNCT_CMDS) + ')', 'g')
|
||||
|
||||
const convertScript = (input: string, table: Record<string, string>, sigil: '^' | '_'): string => {
|
||||
let out = ''
|
||||
let allMapped = true
|
||||
|
||||
for (const ch of input) {
|
||||
const mapped = table[ch]
|
||||
|
||||
if (!mapped) {
|
||||
allMapped = false
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
out += mapped
|
||||
}
|
||||
|
||||
if (allMapped) {
|
||||
return out
|
||||
}
|
||||
|
||||
// Fallback: if the body is a single visible character (e.g. `∞` after
|
||||
// earlier symbol substitution), render it without braces — `^∞` reads
|
||||
// far better than `^{∞}` in a terminal. Multi-char bodies that don't
|
||||
// fully convert use parens (`e^(iπ)`) instead of braces (`e^{iπ}`)
|
||||
// because parens are normal punctuation while braces look like
|
||||
// unrendered LaTeX.
|
||||
const trimmed = input.trim()
|
||||
|
||||
if ([...trimmed].length === 1) {
|
||||
return `${sigil}${trimmed}`
|
||||
}
|
||||
|
||||
return `${sigil}(${trimmed})`
|
||||
}
|
||||
|
||||
// Walk the string and parse `{...}` honouring nested braces. Unlike a
|
||||
// `\{[^{}]*\}` regex this survives `\frac{|t|^{p-1}|P(t)|^p}{...}` where
|
||||
// the numerator contains its own braces from a superscript. Returns the
|
||||
// inner content (without the outer braces) and the offset just past the
|
||||
// closing `}`. Returns null if there is no balanced brace at `start`.
|
||||
const readBraced = (s: string, start: number): { content: string; end: number } | null => {
|
||||
if (s[start] !== '{') {
|
||||
return null
|
||||
}
|
||||
|
||||
let depth = 1
|
||||
let i = start + 1
|
||||
|
||||
while (i < s.length && depth > 0) {
|
||||
const c = s[i]
|
||||
|
||||
// Skip escapes — `\{` and `\}` inside a body are literal braces and
|
||||
// should not change the brace counter.
|
||||
if (c === '\\' && i + 1 < s.length) {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if (c === '{') {
|
||||
depth++
|
||||
} else if (c === '}') {
|
||||
depth--
|
||||
}
|
||||
|
||||
if (depth > 0) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (depth !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { content: s.slice(start + 1, i), end: i + 1 }
|
||||
}
|
||||
|
||||
// Replace every occurrence of `\command{arg}` using balanced-brace parsing
|
||||
// (so `\boxed{x^{n+1}}` works where a `[^{}]*` regex would fail). The
|
||||
// `render` callback receives the inner content already recursed-into, so
|
||||
// `\boxed{\boxed{x}}` resolves outside-in cleanly. Unmatched `\command`
|
||||
// (no following `{...}`) is preserved verbatim.
|
||||
const replaceBracedCommand = (input: string, command: string, render: (content: string) => string): string => {
|
||||
const cmdLen = command.length
|
||||
let out = ''
|
||||
let i = 0
|
||||
|
||||
while (i < input.length) {
|
||||
const idx = input.indexOf(command, i)
|
||||
|
||||
if (idx < 0) {
|
||||
out += input.slice(i)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const after = input[idx + cmdLen]
|
||||
|
||||
if (after && /[A-Za-z]/.test(after)) {
|
||||
out += input.slice(i, idx + cmdLen)
|
||||
i = idx + cmdLen
|
||||
continue
|
||||
}
|
||||
|
||||
out += input.slice(i, idx)
|
||||
|
||||
let p = idx + cmdLen
|
||||
|
||||
while (input[p] === ' ' || input[p] === '\t') p++
|
||||
|
||||
const arg = readBraced(input, p)
|
||||
|
||||
if (!arg) {
|
||||
out += input.slice(idx, p + 1)
|
||||
i = p + 1
|
||||
continue
|
||||
}
|
||||
|
||||
out += render(replaceBracedCommand(arg.content, command, render))
|
||||
i = arg.end
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Replace every `\frac{num}{den}` with `num/den` (parens around either
|
||||
// side when its precedence demands it). The recursion handles nested
|
||||
// fractions naturally: `\frac{1}{\frac{1}{x}}` collapses to `1/(1/x)`
|
||||
// because we recurse into `den` before deciding whether to parenthesise.
|
||||
const replaceFracs = (input: string): string => {
|
||||
let out = ''
|
||||
let i = 0
|
||||
|
||||
while (i < input.length) {
|
||||
const idx = input.indexOf('\\frac', i)
|
||||
|
||||
if (idx < 0) {
|
||||
out += input.slice(i)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const after = input[idx + 5]
|
||||
|
||||
// `(?![A-Za-z])` — protect hypothetical commands like `\fraction`.
|
||||
if (after && /[A-Za-z]/.test(after)) {
|
||||
out += input.slice(i, idx + 5)
|
||||
i = idx + 5
|
||||
continue
|
||||
}
|
||||
|
||||
out += input.slice(i, idx)
|
||||
|
||||
let p = idx + 5
|
||||
|
||||
while (input[p] === ' ' || input[p] === '\t') p++
|
||||
|
||||
const num = readBraced(input, p)
|
||||
|
||||
if (!num) {
|
||||
out += input.slice(idx, p + 1)
|
||||
i = p + 1
|
||||
continue
|
||||
}
|
||||
|
||||
p = num.end
|
||||
|
||||
while (input[p] === ' ' || input[p] === '\t') p++
|
||||
|
||||
const den = readBraced(input, p)
|
||||
|
||||
if (!den) {
|
||||
out += input.slice(idx, p + 1)
|
||||
i = p + 1
|
||||
continue
|
||||
}
|
||||
|
||||
out += `${wrapForFrac(replaceFracs(num.content))}/${wrapForFrac(replaceFracs(den.content))}`
|
||||
i = den.end
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Wrap multi-token expressions in parens so `\frac{a+b}{c}` becomes
|
||||
// `(a+b)/c` rather than `a+b/c`. We wrap whenever inline `/` would
|
||||
// change the meaning — that's any binary operator (`+`, `-`, `*`, `/`)
|
||||
// or whitespace separating tokens. `*` and `/` matter because nested
|
||||
// fractions and products like `\frac{a*b}{c}` and `\frac{1/x}{y}` would
|
||||
// otherwise read as `a*b/c` (right-associative ambiguity) and `1/x/y`.
|
||||
// Atomic factors like `n!`, `x^2`, `\sin x` don't trigger any of these
|
||||
// and stay un-parenthesised — wrapping them just clutters the output.
|
||||
const wrapForFrac = (expr: string) => {
|
||||
const trimmed = expr.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if (/^\(.*\)$/.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if (/[+\-/*]|\s/.test(trimmed)) {
|
||||
return `(${trimmed})`
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function texToUnicode(input: string): string {
|
||||
let s = input
|
||||
|
||||
s = s.replace(/\\mathbb\s*\{([A-Za-z])\}/g, (raw, c: string) => BB[c] ?? raw)
|
||||
s = s.replace(/\\mathcal\s*\{([A-Za-z])\}/g, (raw, c: string) => CAL[c] ?? raw)
|
||||
s = s.replace(/\\mathfrak\s*\{([A-Za-z])\}/g, (raw, c: string) => FRAK[c] ?? raw)
|
||||
s = s.replace(/\\mathbf\s*\{([^{}]+)\}/g, (_, c: string) => c)
|
||||
s = s.replace(/\\mathit\s*\{([^{}]+)\}/g, (_, c: string) => c)
|
||||
s = s.replace(/\\mathrm\s*\{([^{}]+)\}/g, (_, c: string) => c)
|
||||
s = s.replace(/\\text\s*\{([^{}]+)\}/g, (_, c: string) => c)
|
||||
s = s.replace(/\\operatorname\s*\{([^{}]+)\}/g, (_, c: string) => c)
|
||||
|
||||
s = s.replace(/\\overline\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0305`)
|
||||
s = s.replace(/\\hat\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0302`)
|
||||
s = s.replace(/\\bar\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0304`)
|
||||
s = s.replace(/\\tilde\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0303`)
|
||||
s = s.replace(/\\vec\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u20D7`)
|
||||
s = s.replace(/\\dot\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0307`)
|
||||
s = s.replace(/\\ddot\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0308`)
|
||||
|
||||
s = replaceFracs(s)
|
||||
|
||||
// `\boxed{X}` / `\fbox{X}` highlight a final answer. Terminals can't
|
||||
// draw a real box, so we wrap the content in U+0001 / U+0002 control
|
||||
// characters — non-printable, never present in real text — so a renderer
|
||||
// with styled-span hooks can apply a highlight style (inverse video) to
|
||||
// the bracketed region. The OpenTUI engine's preprocessor currently
|
||||
// strips them via BOX_RE (styled-span injection into the native
|
||||
// `<markdown>` renderable is deferred); `texToUnicode` stays pure-string.
|
||||
// Argument is parsed with balanced braces so nested `{...}` from
|
||||
// superscripts / fractions inside the box survive.
|
||||
s = replaceBracedCommand(s, '\\boxed', body => `${BOX_OPEN}${body.trim()}${BOX_CLOSE}`)
|
||||
s = replaceBracedCommand(s, '\\fbox', body => `${BOX_OPEN}${body.trim()}${BOX_CLOSE}`)
|
||||
|
||||
// `\xrightarrow{label}` / `\xleftarrow{label}` collapse to an arrow with
|
||||
// the label inline. LaTeX renders the label above the arrow; in monospace
|
||||
// we put it adjacent — `─label→` is the closest readable approximation.
|
||||
// Run before the symbol pass so the label can still pick up Greek and
|
||||
// operator substitutions afterwards.
|
||||
s = s.replace(/\\xrightarrow\s*\{([^{}]*)\}/g, (_, label: string) => `─${label.trim()}→`)
|
||||
s = s.replace(/\\xleftarrow\s*\{([^{}]*)\}/g, (_, label: string) => `←${label.trim()}─`)
|
||||
s = s.replace(/\\Longrightarrow/g, '⟹')
|
||||
s = s.replace(/\\Longleftarrow/g, '⟸')
|
||||
s = s.replace(/\\Longleftrightarrow/g, '⟺')
|
||||
|
||||
// `\pmod{p}` → ` (mod p)` (LaTeX adds parens automatically); `\pod{p}`
|
||||
// is a paren-less variant; `\tag{n}` is the equation-number annotation
|
||||
// shown to the right of an equation. Collapse to a single-space-prefixed
|
||||
// bracketed form. The leading `\s*` in the pattern absorbs any whitespace
|
||||
// already in the source so we don't end up with `b (mod p)` (double
|
||||
// space) when the user wrote `b \pmod{p}`.
|
||||
s = s.replace(/\s*\\pmod\s*\{([^{}]*)\}/g, (_, p: string) => ` (mod ${p.trim()})`)
|
||||
s = s.replace(/\s*\\pod\s*\{([^{}]*)\}/g, (_, p: string) => ` (${p.trim()})`)
|
||||
s = s.replace(/\s*\\tag\s*\{([^{}]*)\}/g, (_, n: string) => ` (${n.trim()})`)
|
||||
|
||||
// `\big`, `\Big`, `\bigg`, `\Bigg` (with optional `l`/`r`/`m` suffix)
|
||||
// are sizing wrappers analogous to `\left`/`\right` but without the
|
||||
// automatic-pairing semantics. Strip them and leave whatever delimiter
|
||||
// follows. The trailing `(?![A-Za-z])` protects `\bigtriangleup` and
|
||||
// any other letter-continuation command from being shaved.
|
||||
s = s.replace(/\\(?:Bigg|bigg|Big|big)[lrm]?(?![A-Za-z])/g, '')
|
||||
|
||||
// Style / size hints that don't typeset any glyph and only affect how
|
||||
// things would be sized in a real LaTeX engine. In a terminal every
|
||||
// glyph is one monospace cell, so there's nothing to do — drop them
|
||||
// (with any trailing whitespace) so they don't leak through as raw
|
||||
// `\displaystyle` in the output.
|
||||
s = s.replace(/\\(?:scriptscriptstyle|displaystyle|scriptstyle|textstyle|nolimits|limits)(?![A-Za-z])\s*/g, '')
|
||||
|
||||
// `\left` and `\right` are sizing wrappers around any delimiter — bare
|
||||
// (`\left(`), escaped (`\left\{`), or named (`\left\langle`). Strip the
|
||||
// wrapper unconditionally and let the rest of the pipeline (or the
|
||||
// upcoming symbol pass) handle whatever delimiter follows. The optional
|
||||
// `.?` consumes `\left.` / `\right.` which mean "no delimiter".
|
||||
// Lookahead `(?![A-Za-z])` keeps `\leftarrow` / `\leftrightarrow` safe.
|
||||
s = s.replace(/\\left(?![A-Za-z])\.?/g, '')
|
||||
s = s.replace(/\\right(?![A-Za-z])\.?/g, '')
|
||||
|
||||
// Run symbol substitution BEFORE scripts so a body like `^{\infty}`
|
||||
// becomes `^{∞}` first; convertScript can then either map ∞ to a
|
||||
// superscript (it can't — Unicode lacks one) or fall back to `^∞`
|
||||
// by stripping braces around the now-single-character body.
|
||||
//
|
||||
// Punctuation pass first — these can be followed by letters (`\{p`
|
||||
// is "open-brace then p"), so the letter pass's `(?![A-Za-z])` rule
|
||||
// would wrongly block them.
|
||||
s = s.replace(SYMBOL_PUNCT_RE, m => SYMBOLS[m] ?? m)
|
||||
s = s.replace(SYMBOL_LETTER_RE, m => SYMBOLS[m] ?? m)
|
||||
|
||||
// Bare `^c` / `_c` handles ONLY alphanumerics and `+`/`-`/`=`. Parens
|
||||
// are intentionally excluded because the braced-fallback above can
|
||||
// emit `(...)` and we don't want a second pass to greedily convert
|
||||
// its opening paren into `⁽` and orphan the closing one.
|
||||
s = s.replace(/\^\s*\{([^{}]+)\}/g, (_, body: string) => convertScript(body, SUPERSCRIPT, '^'))
|
||||
s = s.replace(/\^([A-Za-z0-9+\-=])/g, (raw, ch: string) => SUPERSCRIPT[ch] ?? raw)
|
||||
s = s.replace(/_\s*\{([^{}]+)\}/g, (_, body: string) => convertScript(body, SUBSCRIPT, '_'))
|
||||
s = s.replace(/_([A-Za-z0-9+\-=])/g, (raw, ch: string) => SUBSCRIPT[ch] ?? raw)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Memory-monitor LOGIC (pure, no node:v8/process/file imports — testable).
|
||||
*
|
||||
* Ports the high-value SMART part of Ink's memory monitor
|
||||
* (`ui-tui/src/lib/memoryMonitor.ts`): the #34095 silent-death EARLY-WARNING.
|
||||
* It deliberately does NOT port Ink's auto heap-snapshot capture — the OpenTUI
|
||||
* engine's always-on `memlog` NDJSON trace (boundary/memlog.ts) is the
|
||||
* diagnosis path, and the rss-vs-heap divergence it records is the better
|
||||
* diagnostic for the native-RSS leak class (#15141) that a V8 heap snapshot
|
||||
* captures poorly anyway. So we skip the #41948 disk-fill bug class entirely.
|
||||
*
|
||||
* The early-warning regime is BELOW the OOM ceiling: Node can OOM from a render-
|
||||
* tree / store blowup at a few hundred MB, well under any "critical" exit
|
||||
* watermark, so a plain level machine never sees it and the death looks silent
|
||||
* (#34095 showed up only as a bare gateway `stdin EOF`). We fire ONCE when heap
|
||||
* both crosses a modest absolute floor AND is climbing steeply (≥150MB between
|
||||
* ticks) — the render-tree-blowup signature — and re-arm only after heap falls
|
||||
* back below the floor. The boundary turns the fire into a visible transcript
|
||||
* system line so the user gets a heads-up before the process dies.
|
||||
*/
|
||||
|
||||
const MB = 1024 ** 2
|
||||
|
||||
/** Heap floor below which we never warn (a small heap climbing is normal). */
|
||||
export const WARN_FLOOR_BYTES = 600 * MB
|
||||
/** Per-tick growth that, combined with crossing the floor, signals a blowup. */
|
||||
export const WARN_GROWTH_STEP_BYTES = 150 * MB
|
||||
|
||||
/** Mutable arm/disarm state for the early-warning detector. */
|
||||
export interface WarnState {
|
||||
/** Previous heapUsed sample; `-1` until the first sample is seen. */
|
||||
lastHeap: number
|
||||
/** Whether we've already fired since the last re-arm (one-shot until reset). */
|
||||
warned: boolean
|
||||
}
|
||||
|
||||
/** A fresh, un-seeded warn state (lastHeap < 0 ⇒ first sample can't "grow"). */
|
||||
export function createWarnState(): WarnState {
|
||||
return { lastHeap: -1, warned: false }
|
||||
}
|
||||
|
||||
export interface WarnEvaluation {
|
||||
/** True exactly on the tick the warning should fire (one-shot). */
|
||||
readonly fire: boolean
|
||||
/** The growth since the previous sample (bytes; 0 on the first sample). */
|
||||
readonly growthBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the early-warning state machine by one sample. MUTATES `state`
|
||||
* (lastHeap + warned) and returns whether to fire this tick.
|
||||
*
|
||||
* Fires once when, while below any OOM ceiling: heap ≥ floor AND grew
|
||||
* ≥ step since the previous sample AND we haven't already fired. Re-arms
|
||||
* (warned ← false) once heap drops back below the floor. The first
|
||||
* (un-seeded) sample only seeds lastHeap and never fires.
|
||||
*/
|
||||
export function evaluateWarn(
|
||||
state: WarnState,
|
||||
heapUsed: number,
|
||||
floorBytes: number = WARN_FLOOR_BYTES,
|
||||
stepBytes: number = WARN_GROWTH_STEP_BYTES
|
||||
): WarnEvaluation {
|
||||
const seeded = state.lastHeap >= 0
|
||||
const growthBytes = seeded ? heapUsed - state.lastHeap : 0
|
||||
let fire = false
|
||||
|
||||
if (seeded) {
|
||||
if (!state.warned && heapUsed >= floorBytes && growthBytes >= stepBytes) {
|
||||
state.warned = true
|
||||
fire = true
|
||||
} else if (heapUsed < floorBytes) {
|
||||
state.warned = false
|
||||
}
|
||||
}
|
||||
|
||||
state.lastHeap = heapUsed
|
||||
return { fire, growthBytes }
|
||||
}
|
||||
|
||||
/** Render the user-facing early-warning line (KB system line, no disk cost). */
|
||||
export function warnLine(heapUsed: number, rss: number, growthBytes: number): string {
|
||||
const mb = (n: number) => Math.round(n / MB)
|
||||
return (
|
||||
`⚠ memory climbing fast — heap ${mb(heapUsed)}MB (+${mb(growthBytes)}MB), rss ${mb(rss)}MB. ` +
|
||||
`If the TUI dies, this is why; relaunch with HERMES_TUI_DIAGNOSTICS=1 for a trace.`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Multi-click selection logic — double-click selects the word, triple-click the
|
||||
* line, and a drag after either extends word-by-word / line-by-line while the
|
||||
* originally clicked span stays selected (native macOS / VS Code behavior).
|
||||
* Ported from the Ink fork's `hermes-ink/src/ink/selection.ts` (wordBoundsAt /
|
||||
* selectLineAt / extendSelection) onto OpenTUI's screen model: the rendered
|
||||
* frame is a flat grid of codepoints (`OptimizedBuffer.buffers.char`), so word
|
||||
* scanning reads the frame the user actually sees — concealed markdown, tool
|
||||
* chrome and all.
|
||||
*
|
||||
* Pure string/number work, no OpenTUI imports — the boundary shim
|
||||
* (`boundary/multiClickSelect.ts`) adapts the live buffer to `ScreenText`.
|
||||
*/
|
||||
|
||||
/** Screen-buffer cell coordinates (0-indexed col/row). */
|
||||
export interface Point {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** Inclusive span from `lo` to `hi` in reading order (row-major). */
|
||||
export interface Span {
|
||||
readonly lo: Point
|
||||
readonly hi: Point
|
||||
}
|
||||
|
||||
/** The multi-clicked span a drag extends from. */
|
||||
export interface AnchorSpan extends Span {
|
||||
readonly kind: 'word' | 'line'
|
||||
}
|
||||
|
||||
/** Read-only view of the rendered frame's character grid. */
|
||||
export interface ScreenText {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
/** Unicode codepoint at cell (x,y); 0 marks a wide-char continuation cell. */
|
||||
readonly codepointAt: (x: number, y: number) => number
|
||||
}
|
||||
|
||||
/** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */
|
||||
export function comparePoints(a: Point, b: Point): number {
|
||||
if (a.y !== b.y) return a.y < b.y ? -1 : 1
|
||||
if (a.x !== b.x) return a.x < b.x ? -1 : 1
|
||||
return 0
|
||||
}
|
||||
|
||||
// Unicode-aware word character matcher: letters (any script), digits, and the
|
||||
// punctuation set iTerm2 treats as word-part by default (`/-+\~_.`). Matching
|
||||
// iTerm2's default means double-clicking a path like `src/logic/multiClick.ts`
|
||||
// selects the whole path — the muscle memory terminal users have.
|
||||
const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u
|
||||
|
||||
/**
|
||||
* Character class for double-click word-expansion: 0 = whitespace/empty,
|
||||
* 1 = word char, 2 = other punctuation. Cells with the same class as the
|
||||
* clicked cell are one run; a class change is a boundary — so double-click on
|
||||
* `foo` selects `foo`, on `->` selects `->`, on spaces the whitespace run.
|
||||
*/
|
||||
function charClass(cp: number): 0 | 1 | 2 {
|
||||
if (cp === 0 || cp === 32) return 0
|
||||
if (WORD_CHAR.test(String.fromCodePoint(cp))) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds of the same-class character run at (x, y), or null when the click is
|
||||
* out of bounds. Wide-char continuation cells (codepoint 0) belong to the head
|
||||
* glyph at their left: a click on one resolves to the head, the left scan
|
||||
* steps over them to the head's class, and the right scan includes them in the
|
||||
* span so the highlight covers the full glyph.
|
||||
*/
|
||||
export function wordSpanAt(screen: ScreenText, x: number, y: number): Span | null {
|
||||
if (y < 0 || y >= screen.height || x < 0 || x >= screen.width) return null
|
||||
|
||||
// Land on a continuation cell → step back to the wide-char head.
|
||||
let c = x
|
||||
while (c > 0 && screen.codepointAt(c, y) === 0) c -= 1
|
||||
|
||||
const cls = charClass(screen.codepointAt(c, y))
|
||||
|
||||
let lo = c
|
||||
while (lo > 0) {
|
||||
let prev = lo - 1
|
||||
while (prev > 0 && screen.codepointAt(prev, y) === 0) prev -= 1
|
||||
if (charClass(screen.codepointAt(prev, y)) !== cls) break
|
||||
lo = prev
|
||||
}
|
||||
|
||||
let hi = c
|
||||
while (hi < screen.width - 1) {
|
||||
const cp = screen.codepointAt(hi + 1, y)
|
||||
// A continuation cell after a run member is the tail of the run's last
|
||||
// wide glyph — include it and keep scanning.
|
||||
if (cp !== 0 && charClass(cp) !== cls) break
|
||||
hi += 1
|
||||
}
|
||||
|
||||
return { lo: { x: lo, y }, hi: { x: hi, y } }
|
||||
}
|
||||
|
||||
/** The full row as a span (triple-click). Null when the row is out of bounds —
|
||||
* per-renderable `getSelectedText` trims what shouldn't copy, matching the
|
||||
* Ink fork where line-select spans the visual row. */
|
||||
export function lineSpanAt(screen: ScreenText, y: number): Span | null {
|
||||
if (y < 0 || y >= screen.height || screen.width <= 0) return null
|
||||
return { lo: { x: 0, y }, hi: { x: screen.width - 1, y } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drag at (x, y) puts the selection while an anchor span is held:
|
||||
* the span under the mouse (word at the pointer, or its row in line mode;
|
||||
* raw cell fallback when the pointer is out of bounds) is merged with the
|
||||
* anchor span so the original word/line always stays selected.
|
||||
*/
|
||||
export function extendedSelection(
|
||||
span: AnchorSpan,
|
||||
screen: ScreenText,
|
||||
x: number,
|
||||
y: number
|
||||
): { anchor: Point; focus: Point } {
|
||||
let mouseLo: Point
|
||||
let mouseHi: Point
|
||||
|
||||
if (span.kind === 'word') {
|
||||
const b = wordSpanAt(screen, x, y)
|
||||
mouseLo = b ? b.lo : { x, y }
|
||||
mouseHi = b ? b.hi : { x, y }
|
||||
} else {
|
||||
const row = Math.max(0, Math.min(y, screen.height - 1))
|
||||
mouseLo = { x: 0, y: row }
|
||||
mouseHi = { x: screen.width - 1, y: row }
|
||||
}
|
||||
|
||||
// Mouse target entirely before the anchor span → grow backward from its end;
|
||||
// entirely after → grow forward from its start; overlapping → just the span.
|
||||
if (comparePoints(mouseHi, span.lo) < 0) return { anchor: span.hi, focus: mouseLo }
|
||||
if (comparePoints(mouseLo, span.hi) > 0) return { anchor: span.lo, focus: mouseHi }
|
||||
return { anchor: span.lo, focus: span.hi }
|
||||
}
|
||||
|
||||
/** Same chain window the Ink fork uses (`App.tsx` MULTI_CLICK_*). */
|
||||
export const MULTI_CLICK_TIMEOUT_MS = 500
|
||||
export const MULTI_CLICK_DISTANCE = 1
|
||||
|
||||
/**
|
||||
* Click-chain counter: a press within MULTI_CLICK_TIMEOUT_MS and
|
||||
* MULTI_CLICK_DISTANCE of the previous press continues the chain, otherwise
|
||||
* the count resets to 1. The returned count is capped at 3 — quadruple+
|
||||
* clicks stay line-select, like every terminal/editor.
|
||||
*/
|
||||
export function createClickCounter(): (x: number, y: number, now: number) => 1 | 2 | 3 {
|
||||
let lastTime = 0
|
||||
let lastX = -1
|
||||
let lastY = -1
|
||||
let count = 0
|
||||
|
||||
return (x, y, now) => {
|
||||
const chained =
|
||||
now - lastTime <= MULTI_CLICK_TIMEOUT_MS &&
|
||||
Math.abs(x - lastX) <= MULTI_CLICK_DISTANCE &&
|
||||
Math.abs(y - lastY) <= MULTI_CLICK_DISTANCE
|
||||
count = chained ? count + 1 : 1
|
||||
lastTime = now
|
||||
lastX = x
|
||||
lastY = y
|
||||
return count >= 3 ? 3 : (count as 1 | 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Notification → desktop-OSC decision. EVERY notification renders an inline
|
||||
* transcript card (so there's nothing to decide there); this only decides whether
|
||||
* a notification is important enough to ALSO fire a desktop/terminal OSC ping
|
||||
* (to pull the user back). The OSC payload is termChrome's `TermNotification`;
|
||||
* the boundary (terminalChrome) owns the actual escape-sequence write.
|
||||
*/
|
||||
import type { ActivityNotification } from './backgroundActivity.ts'
|
||||
import type { TermNotification } from './termChrome.ts'
|
||||
|
||||
/** Kind substrings that mark a "the work finished, look here" notification —
|
||||
* matched case-insensitively anywhere in the kind. */
|
||||
const COMPLETION_KIND_HINTS = ['complete', 'done', 'finish']
|
||||
|
||||
function isImportant(n: ActivityNotification): boolean {
|
||||
if (n.level === 'error' || n.level === 'warn') return true
|
||||
const kind = n.kind.toLowerCase()
|
||||
return COMPLETION_KIND_HINTS.some(hint => kind.includes(hint))
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop OSC notification for `n`, or `undefined` when it's not important
|
||||
* enough to interrupt — level 'error'/'warn', or a kind containing
|
||||
* 'complete'/'done'/'finish' (case-insensitive). Title is always 'Hermes' with
|
||||
* the notification text as the body.
|
||||
*/
|
||||
export function notificationOsc(n: ActivityNotification): TermNotification | undefined {
|
||||
return isImportant(n) ? { body: n.text, title: 'Hermes' } : undefined
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Transient-notice seam (per-block copy feedback, Epic: design pass piece 2).
|
||||
* Deep view nodes (e.g. the per-block `⧉` copy affordance in messageLine) need
|
||||
* to flash a short notice ("Copied") on the EXISTING hint line (StatusLine —
|
||||
* the same surface the entry's flashHint uses for /copy and selection-copy),
|
||||
* but they don't hold the store. The store registers its `setHint` here at
|
||||
* creation (one live store per app; the latest registration wins, which is
|
||||
* also what headless tests want), and `flashNotice` mirrors the entry's
|
||||
* flashHint contract: set, then auto-clear after `ms` unless something newer
|
||||
* replaced it. No-op when nothing is registered (bare component tests).
|
||||
*/
|
||||
|
||||
type NotifySink = (text: string | undefined) => void
|
||||
|
||||
let sink: NotifySink | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let current: string | undefined
|
||||
|
||||
/** Register (or clear) the app-wide notice sink — the store's `setHint`. */
|
||||
export function registerNotifier(fn: NotifySink | undefined): void {
|
||||
sink = fn
|
||||
}
|
||||
|
||||
/** Flash a transient notice on the hint line; auto-clears after `ms`. */
|
||||
export function flashNotice(text: string, ms = 1500): void {
|
||||
sink?.(text)
|
||||
current = text
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
if (current === text) {
|
||||
sink?.(undefined)
|
||||
current = undefined
|
||||
}
|
||||
}, ms)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Pasted-text placeholders (free-code's model). A large paste isn't dumped raw
|
||||
* into the composer — instead a compact `[Pasted text #N +M lines]` chip is shown
|
||||
* and the real content is held in a Map, then expanded back on submit. Pure + no
|
||||
* OpenTUI imports → trivially unit-testable.
|
||||
*
|
||||
* The store is created ONCE per session (entry) and passed to the Composer, so it
|
||||
* survives the composer remounting when overlays open/close (a per-composer store
|
||||
* would lose a pending paste mid-compose).
|
||||
*/
|
||||
|
||||
export interface PasteStore {
|
||||
/** Register a pasted block; returns the placeholder to insert into the input. */
|
||||
add(text: string): string
|
||||
/** Replace every `[Pasted text #N …]` placeholder with its stored content. */
|
||||
expand(input: string): string
|
||||
/** Drop all stored pastes (call after a successful submit). */
|
||||
clear(): void
|
||||
}
|
||||
|
||||
// Matches `[Pasted text #12]` and `[Pasted text #12 +34 lines]`. The id is the key.
|
||||
const REF = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g
|
||||
|
||||
export function createPasteStore(): PasteStore {
|
||||
const map = new Map<number, string>()
|
||||
let seq = 0
|
||||
return {
|
||||
add(text) {
|
||||
const id = ++seq
|
||||
map.set(id, text)
|
||||
const lines = text.split('\n').length
|
||||
return lines > 1 ? `[Pasted text #${id} +${lines} lines]` : `[Pasted text #${id}]`
|
||||
},
|
||||
// String.replace(/g) is a SINGLE left-to-right pass over the ORIGINAL string,
|
||||
// so content inserted for one ref is never re-scanned for another ref —
|
||||
// a pasted block that itself contains `[Pasted text #k]` is safe.
|
||||
expand(input) {
|
||||
return (input ?? '').replace(REF, (m, id: string) => map.get(Number(id)) ?? m)
|
||||
},
|
||||
clear() {
|
||||
map.clear()
|
||||
seq = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A paste big enough to placeholder rather than inline (conservative thresholds). */
|
||||
export function shouldPlaceholder(text: string): boolean {
|
||||
return text.split('\n').length >= 4 || text.length > 400
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* promptHistory — pure logic for the Esc+Esc session prompt viewer (Epic 5).
|
||||
*
|
||||
* Model: free-code's rewind dialog (`useDoublePress.ts`, `MessageSelector.tsx`)
|
||||
* — 800ms double-press window, only-when-input-empty trigger, 7 visible rows
|
||||
* newest-first with a centered window, Enter → confirm step.
|
||||
*
|
||||
* Semantics (spec Epic 5, RESOLVED block):
|
||||
* - Entries are THIS session's user prompts from the store transcript
|
||||
* (NOT the per-dir JSONL composer history), newest first. Empty → no modal.
|
||||
* - Undo = conversation layer (`/undo` removes the LAST user/assistant
|
||||
* exchange; files kept) → offered ONLY for the most recent prompt. We never
|
||||
* fake arbitrary-depth conversation rewind.
|
||||
* - Rollback = filesystem layer (`/rollback` checkpoints; conversation kept).
|
||||
* Prompt→checkpoint mapping isn't feasible client-side (neither store
|
||||
* messages nor `session.history` carry timestamps to correlate against
|
||||
* `rollback.list`'s checkpoint timestamps), so the honest action is plain
|
||||
* `/rollback`: the gateway's own checkpoint list lands in the transcript
|
||||
* and the user picks `/rollback <n>` from real data.
|
||||
*/
|
||||
|
||||
/** Double-press window (free-code `DOUBLE_PRESS_TIMEOUT_MS`). */
|
||||
export const DOUBLE_PRESS_WINDOW_MS = 800
|
||||
|
||||
/** Max visible prompt rows before the list windows (free-code `MAX_VISIBLE_MESSAGES`). */
|
||||
export const MAX_VISIBLE = 7
|
||||
|
||||
/**
|
||||
* Double-press detector (pure state machine; the free-code hook without React).
|
||||
* `press(now)` returns true on the SECOND press within the window — and then
|
||||
* disarms, so a third press starts a fresh cycle. `reset()` disarms (call it on
|
||||
* any intervening key, and never call `press` for an Esc something else
|
||||
* consumed — that's what keeps a dropdown-dismiss Esc from arming).
|
||||
*/
|
||||
export interface DoublePress {
|
||||
press(now?: number): boolean
|
||||
reset(): void
|
||||
}
|
||||
|
||||
export function createDoublePress(windowMs: number = DOUBLE_PRESS_WINDOW_MS): DoublePress {
|
||||
let armedAt: number | undefined
|
||||
return {
|
||||
// performance.now() is monotonic (Node) — an NTP/wall-clock jump between
|
||||
// two presses can't break or spuriously satisfy the window (review finding).
|
||||
press(now: number = performance.now()): boolean {
|
||||
if (armedAt !== undefined && now - armedAt <= windowMs) {
|
||||
armedAt = undefined
|
||||
return true
|
||||
}
|
||||
armedAt = now
|
||||
return false
|
||||
},
|
||||
reset(): void {
|
||||
armedAt = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One viewer row: a user prompt of THIS session. `index` is its position in
|
||||
* the source transcript (stable identity across renders). */
|
||||
export interface PromptEntry {
|
||||
readonly index: number
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Source the viewer entries from the store transcript: USER prompts only,
|
||||
* non-empty, NEWEST FIRST. Session-only by construction (the store holds only
|
||||
* this session's messages). Empty session → [] (the trigger shows nothing).
|
||||
*/
|
||||
export function promptHistoryEntries(messages: ReadonlyArray<{ readonly role: string; text: string }>): PromptEntry[] {
|
||||
const entries: PromptEntry[] = []
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (m && m.role === 'user' && m.text.trim() !== '') entries.push({ index: i, text: m.text })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** A confirm-step action. */
|
||||
export type HistoryAction = 'undo' | 'rollback'
|
||||
|
||||
export interface ConfirmOption {
|
||||
readonly action: HistoryAction
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
/** The exact signed-off confirm labels (spec Epic 5). */
|
||||
export const UNDO_LABEL = 'Undo — rewind the conversation (files kept)'
|
||||
export const ROLLBACK_LABEL = 'Rollback — restore files from checkpoint (conversation kept)'
|
||||
|
||||
/**
|
||||
* The confirm-step options for a selected entry. `/undo` only removes the LAST
|
||||
* exchange, so Undo is offered ONLY for the most recent prompt (`isLatest`) —
|
||||
* an option the gateway can't honor is hidden, never a dead button. Rollback
|
||||
* (filesystem checkpoints) applies regardless of the selected depth.
|
||||
*/
|
||||
export function confirmOptions(isLatest: boolean): ConfirmOption[] {
|
||||
const options: ConfirmOption[] = []
|
||||
if (isLatest) options.push({ action: 'undo', label: UNDO_LABEL })
|
||||
options.push({ action: 'rollback', label: ROLLBACK_LABEL })
|
||||
return options
|
||||
}
|
||||
|
||||
/** The slash command an action dispatches — through the SAME command path the
|
||||
* composer uses (`dispatchSlash` → `slash.exec`/`command.dispatch`). */
|
||||
export function actionCommand(action: HistoryAction): string {
|
||||
return action === 'undo' ? '/undo' : '/rollback'
|
||||
}
|
||||
|
||||
/**
|
||||
* First visible row index for a list window: keep the selection centered until
|
||||
* the window hits either end (free-code `firstVisibleIndex`). Total ≤ visible
|
||||
* → 0 (everything shows).
|
||||
*/
|
||||
export function windowStart(selected: number, total: number, visible: number = MAX_VISIBLE): number {
|
||||
return Math.max(0, Math.min(selected - Math.floor(visible / 2), total - visible))
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* /replay — spawn-tree inspector logic (Epic 3 port; Ink ref
|
||||
* `app/slash/commands/ops.ts` /replay + `spawnHistoryStore.ts`). The gateway
|
||||
* archives each completed delegation fan-out as a JSON snapshot
|
||||
* (`spawn_tree.save`); these helpers read `spawn_tree.list` / `spawn_tree.load`
|
||||
* payloads and format them as PAGER TEXT — the native engine renders replays
|
||||
* through the existing pager overlay instead of Ink's agents overlay.
|
||||
*
|
||||
* All readers are defensive (wire JSON is loose, snapshots cross versions).
|
||||
*/
|
||||
|
||||
export interface SpawnTreeEntry {
|
||||
path: string
|
||||
label: string
|
||||
count: number
|
||||
/** Epoch SECONDS (gateway convention). */
|
||||
finishedAt?: number
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v ? v : undefined
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||
}
|
||||
|
||||
/** Map a `spawn_tree.list` result ({entries:[…]}) into typed rows (pathless rows dropped). */
|
||||
export function readSpawnTreeEntries(result: unknown): SpawnTreeEntry[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const entries = (result as { entries?: unknown }).entries
|
||||
if (!Array.isArray(entries)) return []
|
||||
const out: SpawnTreeEntry[] = []
|
||||
for (const e of entries) {
|
||||
if (!e || typeof e !== 'object') continue
|
||||
const o = e as { [k: string]: unknown }
|
||||
const path = str(o['path'])
|
||||
if (!path) continue
|
||||
const entry: SpawnTreeEntry = {
|
||||
count: num(o['count']) ?? 0,
|
||||
label: str(o['label']) ?? '',
|
||||
path
|
||||
}
|
||||
const finishedAt = num(o['finished_at'])
|
||||
if (finishedAt !== undefined) entry.finishedAt = finishedAt
|
||||
const sessionId = str(o['session_id'])
|
||||
if (sessionId !== undefined) entry.sessionId = sessionId
|
||||
out.push(entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function fmtWhen(epochSeconds: number | undefined): string {
|
||||
if (epochSeconds === undefined) return '?'
|
||||
try {
|
||||
return new Date(epochSeconds * 1000).toLocaleString()
|
||||
} catch {
|
||||
return '?'
|
||||
}
|
||||
}
|
||||
|
||||
/** The bare `/replay` listing: indexed rows the user replays by number. */
|
||||
export function formatSpawnTreeList(entries: readonly SpawnTreeEntry[]): string {
|
||||
const lines: string[] = ['Archived spawn trees — /replay <n> to view, /replay <path> for any snapshot', '']
|
||||
entries.forEach((e, i) => {
|
||||
const label = e.label || `${e.count} subagent${e.count === 1 ? '' : 's'}`
|
||||
lines.push(`${String(i + 1).padStart(3)}. ${fmtWhen(e.finishedAt)} · ${e.count}× — ${label}`)
|
||||
lines.push(` ${e.path}`)
|
||||
})
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** Status glyph for an archived subagent row. */
|
||||
function statusGlyph(status: string): string {
|
||||
if (status === 'completed') return '✓'
|
||||
if (status === 'error' || status === 'failed' || status === 'timeout') return '✗'
|
||||
if (status === 'interrupted') return '⏹'
|
||||
return '●'
|
||||
}
|
||||
|
||||
/** One archived subagent → its pager lines (indented by spawn depth). */
|
||||
function subagentLines(raw: unknown, index: number): string[] {
|
||||
const o = (raw && typeof raw === 'object' ? raw : {}) as { [k: string]: unknown }
|
||||
const depth = num(o['depth']) ?? 0
|
||||
const pad = ' '.repeat(Math.max(0, depth))
|
||||
const status = str(o['status']) ?? 'completed'
|
||||
const goal = str(o['goal']) ?? 'subagent'
|
||||
const lines = [`${pad}${statusGlyph(status)} [${index + 1}] ${goal}`]
|
||||
const meta: string[] = [status]
|
||||
const model = str(o['model'])
|
||||
if (model) meta.push(model)
|
||||
const duration = num(o['durationSeconds'])
|
||||
if (duration !== undefined) meta.push(`${Math.round(duration)}s`)
|
||||
const tools = num(o['toolCount'])
|
||||
if (tools) meta.push(`${tools} tool${tools === 1 ? '' : 's'}`)
|
||||
const tokIn = num(o['inputTokens'])
|
||||
const tokOut = num(o['outputTokens'])
|
||||
if (tokIn !== undefined || tokOut !== undefined) meta.push(`${tokIn ?? 0} in / ${tokOut ?? 0} out tok`)
|
||||
lines.push(`${pad} ${meta.join(' · ')}`)
|
||||
const summary = str(o['summary'])
|
||||
if (summary) for (const s of summary.split('\n')) lines.push(`${pad} ${s}`)
|
||||
const notes = o['notes']
|
||||
if (Array.isArray(notes)) {
|
||||
for (const note of notes) if (typeof note === 'string' && note) lines.push(`${pad} · ${note}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** A loaded snapshot (`spawn_tree.load` payload) → the full pager text. */
|
||||
export function formatSpawnTree(payload: unknown): string {
|
||||
const o = (payload && typeof payload === 'object' ? payload : {}) as { [k: string]: unknown }
|
||||
const subagents = Array.isArray(o['subagents']) ? (o['subagents'] as unknown[]) : []
|
||||
const header: string[] = []
|
||||
const label = str(o['label'])
|
||||
header.push(label ?? 'spawn tree')
|
||||
const meta: string[] = []
|
||||
const sessionId = str(o['session_id'])
|
||||
if (sessionId) meta.push(`session ${sessionId}`)
|
||||
meta.push(`finished ${fmtWhen(num(o['finished_at']))}`)
|
||||
meta.push(`${subagents.length} subagent${subagents.length === 1 ? '' : 's'}`)
|
||||
header.push(meta.join(' · '))
|
||||
if (!subagents.length) return [...header, '', '(snapshot empty or unreadable)'].join('\n')
|
||||
const body = subagents.flatMap((s, i) => ['', ...subagentLines(s, i)])
|
||||
return [...header, ...body].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Resume snapshot mapper (spec §1 lifecycle; gotcha §8 #5). Maps the
|
||||
* `session.resume` response `messages` (tui_gateway `_history_to_messages`) into
|
||||
* the store's `Message[]`. Each history entry is either `{role, text}` (user/
|
||||
* assistant/system) or `{role:'tool', name, context}` (NO text — render it).
|
||||
*
|
||||
* Tool rows are folded into the PRECEDING assistant turn's ordered `parts[]`
|
||||
* (state:'complete', summary=context) so a resumed transcript renders inline like
|
||||
* a live one. Resumed assistant text is given a single text part so it renders
|
||||
* through the native markdown path. IDs are `r*` (distinct from live `p*`).
|
||||
*/
|
||||
import type { Message, Part, SessionItem, ToolPartState } from './store.ts'
|
||||
import { stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
function readNum(value: unknown, key: string): number {
|
||||
if (!value || typeof value !== 'object') return 0
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'number' ? v : 0
|
||||
}
|
||||
|
||||
/** Map a `session.list` result into switcher rows (loose-typed read). */
|
||||
export function mapSessionList(result: unknown): SessionItem[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const sessions = (result as { sessions?: unknown }).sessions
|
||||
if (!Array.isArray(sessions)) return []
|
||||
const out: SessionItem[] = []
|
||||
for (const s of sessions) {
|
||||
const id = readStr(s, 'id')
|
||||
if (!id) continue
|
||||
out.push({
|
||||
id,
|
||||
messageCount: readNum(s, 'message_count'),
|
||||
preview: readStr(s, 'preview') ?? '',
|
||||
title: readStr(s, 'title') ?? ''
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function mapResumeHistory(history: unknown): Message[] {
|
||||
if (!Array.isArray(history)) return []
|
||||
const out: Message[] = []
|
||||
let seq = 0
|
||||
const id = () => `r${++seq}`
|
||||
let currentAssistant: Message | undefined
|
||||
|
||||
for (const raw of history) {
|
||||
const role = readStr(raw, 'role')
|
||||
|
||||
if (role === 'tool') {
|
||||
const name = readStr(raw, 'name') ?? 'tool'
|
||||
const context = readStr(raw, 'context')
|
||||
const tool: ToolPartState = { type: 'tool', id: id(), name, state: 'complete' }
|
||||
// Match the live tool part exactly (item 1): primary-arg preview in the
|
||||
// header, plus the (capped) output so resumed tools are collapsible too.
|
||||
if (context) tool.argsPreview = context
|
||||
const rawResult = readStr(raw, 'result_text')
|
||||
if (rawResult) {
|
||||
const { body, omittedNote } = stripOmittedNote(rawResult)
|
||||
const resultText = stripToolEnvelope(body)
|
||||
if (resultText) {
|
||||
tool.resultText = resultText
|
||||
tool.lineCount = resultText.replace(/\s+$/, '').split('\n').length
|
||||
}
|
||||
if (omittedNote) tool.omittedNote = omittedNote
|
||||
}
|
||||
const args = (raw as { args?: unknown }).args
|
||||
if (args && typeof args === 'object') {
|
||||
try {
|
||||
tool.argsText = JSON.stringify(args, null, 2)
|
||||
} catch {
|
||||
/* unstringifiable — leave unset */
|
||||
}
|
||||
}
|
||||
if (!currentAssistant) {
|
||||
currentAssistant = { role: 'assistant', text: '', parts: [] }
|
||||
out.push(currentAssistant)
|
||||
}
|
||||
;(currentAssistant.parts ??= []).push(tool)
|
||||
continue
|
||||
}
|
||||
|
||||
const text = readStr(raw, 'text') ?? ''
|
||||
if (role === 'assistant') {
|
||||
const parts: Part[] = text ? [{ type: 'text', id: id(), text }] : []
|
||||
currentAssistant = { role: 'assistant', text, parts }
|
||||
out.push(currentAssistant)
|
||||
} else if (role === 'user' || role === 'system') {
|
||||
out.push({ role, text })
|
||||
currentAssistant = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* sessionPicker.ts — pure logic for the tabbed resume picker (design doc
|
||||
* docs/plans/opentui-resume-picker.md §A/§B item 5; supersedes the flat
|
||||
* SessionSwitcher). Everything here is view-free and vitest-covered:
|
||||
*
|
||||
* - tab definitions + source→tab classification (Recent = interactive
|
||||
* cli/tui/acp + unknown/custom; Cron = cron; Gateways = the known platform
|
||||
* sources; All = everything minus the deny-listed `tool`),
|
||||
* - the `session.list` params each tab queries with (`sources` allow-list —
|
||||
* note the one honest gap: unknown/custom sources CLASSIFY as Recent but
|
||||
* can't be expressed in an allow-list, so they surface under All),
|
||||
* - the client-side search filter chain over title/preview/cwd/id (reuses
|
||||
* fuzzy.ts — same scorer as the model picker),
|
||||
* - the key-routing decision table (pattern: completionMenu's routeMenuKey),
|
||||
* - the relative-time formatter + row-meta composer (time · source · N msgs
|
||||
* · tail-truncated cwd),
|
||||
* - `/sessions <tab>` arg parsing and the `/resume <id|name>` resolver.
|
||||
*/
|
||||
import { fuzzyFilter, type FuzzyField } from './fuzzy.ts'
|
||||
|
||||
// ── tabs + classification ─────────────────────────────────────────────────
|
||||
|
||||
export type SessionTabId = 'recent' | 'cron' | 'gateways' | 'all'
|
||||
|
||||
/** Tab strip order + labels (design doc §A — Recent is the default). */
|
||||
export const SESSION_TABS: ReadonlyArray<{ id: SessionTabId; label: string }> = [
|
||||
{ id: 'recent', label: 'Recent' },
|
||||
{ id: 'cron', label: 'Cron' },
|
||||
{ id: 'gateways', label: 'Gateways' },
|
||||
{ id: 'all', label: 'All' }
|
||||
]
|
||||
|
||||
/** Interactive sources — the Recent tab's allow-list. */
|
||||
export const INTERACTIVE_SOURCES: readonly string[] = ['cli', 'tui', 'acp']
|
||||
|
||||
/** Known platform/gateway sources (the Gateways tab's allow-list). The gateway
|
||||
* itself deny-lists only `tool`, so this list is the picker's working set of
|
||||
* "messaging platform" tags; new platforms join here (or show under All). */
|
||||
export const PLATFORM_SOURCES: readonly string[] = [
|
||||
'telegram',
|
||||
'discord',
|
||||
'slack',
|
||||
'whatsapp',
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'teams',
|
||||
'email',
|
||||
'webhook',
|
||||
'x',
|
||||
'twitter',
|
||||
'mastodon',
|
||||
'irc',
|
||||
'mattermost'
|
||||
]
|
||||
|
||||
/** Classify a session `source` tag into its home tab (`tool` = deny-listed —
|
||||
* never shown). Unknown/custom sources (incl. empty) default to Recent per
|
||||
* the design table: they're assumed interactive `HERMES_SESSION_SOURCE`s. */
|
||||
export function classifySource(source: string | undefined): 'recent' | 'cron' | 'gateways' | 'tool' {
|
||||
const s = (source ?? '').trim().toLowerCase()
|
||||
if (s === 'tool') return 'tool'
|
||||
if (s === 'cron') return 'cron'
|
||||
if (PLATFORM_SOURCES.includes(s)) return 'gateways'
|
||||
return 'recent'
|
||||
}
|
||||
|
||||
/** Whether a row with this source belongs on the given tab. */
|
||||
export function tabAccepts(tab: SessionTabId, source: string | undefined): boolean {
|
||||
const cls = classifySource(source)
|
||||
if (cls === 'tool') return false
|
||||
return tab === 'all' || cls === tab
|
||||
}
|
||||
|
||||
/**
|
||||
* The `session.list` params a tab queries with. Cron/Gateways push an exact
|
||||
* `sources` allow-list to the gateway; All omits it (the gateway deny-lists
|
||||
* `tool` itself). Recent sends the interactive allow-list — the one honest gap
|
||||
* vs `classifySource` (unknown/custom sources can't be allow-listed, so they
|
||||
* appear under All only); fetching everything and filtering client-side would
|
||||
* make Recent unusable in cron-heavy DBs (1500+ cron rows drown the page).
|
||||
*/
|
||||
export function listParamsFor(tab: SessionTabId, offset: number, limit: number): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = { limit, offset }
|
||||
if (tab === 'recent') return { ...base, sources: [...INTERACTIVE_SOURCES] }
|
||||
if (tab === 'cron') return { ...base, sources: ['cron'] }
|
||||
if (tab === 'gateways') return { ...base, sources: [...PLATFORM_SOURCES] }
|
||||
return base
|
||||
}
|
||||
|
||||
// ── session.list row mapping ──────────────────────────────────────────────
|
||||
|
||||
/** One picker row — the widened `session.list` projection (gateway 529d8084b). */
|
||||
export interface SessionRow {
|
||||
id: string
|
||||
title: string
|
||||
preview: string
|
||||
source: string
|
||||
messageCount: number
|
||||
startedAt: number
|
||||
lastActive: number
|
||||
endedAt?: number
|
||||
model?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
function readNum(value: unknown, key: string): number {
|
||||
if (!value || typeof value !== 'object') return 0
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'number' ? v : 0
|
||||
}
|
||||
|
||||
/** Map a widened `session.list` result into rows + the honesty flag. */
|
||||
export function mapSessionRows(result: unknown): { rows: SessionRow[]; truncated: boolean } {
|
||||
if (!result || typeof result !== 'object') return { rows: [], truncated: false }
|
||||
const sessions = (result as { sessions?: unknown }).sessions
|
||||
const truncated = (result as { truncated?: unknown }).truncated === true
|
||||
if (!Array.isArray(sessions)) return { rows: [], truncated }
|
||||
const rows: SessionRow[] = []
|
||||
for (const s of sessions) {
|
||||
const id = readStr(s, 'id')
|
||||
if (!id) continue
|
||||
const row: SessionRow = {
|
||||
id,
|
||||
lastActive: readNum(s, 'last_active') || readNum(s, 'started_at'),
|
||||
messageCount: readNum(s, 'message_count'),
|
||||
preview: readStr(s, 'preview') ?? '',
|
||||
source: readStr(s, 'source') ?? '',
|
||||
startedAt: readNum(s, 'started_at'),
|
||||
title: readStr(s, 'title') ?? ''
|
||||
}
|
||||
const endedAt = readNum(s, 'ended_at')
|
||||
if (endedAt) row.endedAt = endedAt
|
||||
const model = readStr(s, 'model')
|
||||
if (model) row.model = model
|
||||
const cwd = readStr(s, 'cwd')
|
||||
if (cwd) row.cwd = cwd
|
||||
rows.push(row)
|
||||
}
|
||||
return { rows, truncated }
|
||||
}
|
||||
|
||||
// ── search filter chain (client-side, within the active tab) ──────────────
|
||||
|
||||
/** Fuzzy haystacks of a row: title ×2 (primary), preview, cwd, id. */
|
||||
export function sessionFields(row: SessionRow): FuzzyField[] {
|
||||
const fields: FuzzyField[] = []
|
||||
if (row.title) fields.push({ text: row.title, weight: 2 })
|
||||
if (row.preview) fields.push({ text: row.preview })
|
||||
if (row.cwd) fields.push({ text: row.cwd })
|
||||
fields.push({ text: row.id })
|
||||
return fields
|
||||
}
|
||||
|
||||
/** Filter + rank rows by the search query (empty → all rows, fetch order). */
|
||||
export function filterSessions(query: string, rows: readonly SessionRow[]): SessionRow[] {
|
||||
return fuzzyFilter(query, rows, sessionFields)
|
||||
}
|
||||
|
||||
// ── this-directory grouping ───────────────────────────────────────────────
|
||||
|
||||
/** Path equality for cwd grouping: trim + drop trailing slashes. Pure string
|
||||
* work (no fs) — rows carry the gateway's already-absolute paths. */
|
||||
export function normalizeCwd(path: string | undefined): string {
|
||||
return (path ?? '').trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** Display order with sessions started in the CURRENT directory first.
|
||||
*
|
||||
* Browse mode only: while a search query is active the fuzzy score owns the
|
||||
* order (relevance beats locality), so `hereCount` is 0 and rows pass through.
|
||||
* Stable within both groups (each keeps the gateway's recency order). The
|
||||
* view renders section captions off `hereCount`; selection math is untouched
|
||||
* because this just reorders the one flat list.
|
||||
*/
|
||||
export function orderRowsForCwd(
|
||||
rows: SessionRow[],
|
||||
currentCwd: string | undefined,
|
||||
query: string
|
||||
): { rows: SessionRow[]; hereCount: number } {
|
||||
const here = normalizeCwd(currentCwd)
|
||||
if (!here || query.trim()) return { hereCount: 0, rows }
|
||||
const local: SessionRow[] = []
|
||||
const elsewhere: SessionRow[] = []
|
||||
for (const row of rows) (normalizeCwd(row.cwd) === here ? local : elsewhere).push(row)
|
||||
if (!local.length) return { hereCount: 0, rows }
|
||||
return { hereCount: local.length, rows: [...local, ...elsewhere] }
|
||||
}
|
||||
|
||||
// ── key routing (pattern: completionMenu.ts routeMenuKey) ────────────────
|
||||
|
||||
export interface SessionPickerKeyContext {
|
||||
/** Whether the inline Ctrl+R rename is active (it owns Enter/Esc). */
|
||||
renaming: boolean
|
||||
/** Whether the search query is empty (←/→ only cycle tabs when it is). */
|
||||
queryEmpty: boolean
|
||||
}
|
||||
|
||||
export type SessionPickerAction =
|
||||
| { kind: 'close' }
|
||||
| { kind: 'resume' }
|
||||
| { kind: 'move'; dir: 1 | -1 }
|
||||
| { kind: 'cycle-tab'; dir: 1 | -1 }
|
||||
| { kind: 'preview' }
|
||||
| { kind: 'rename' }
|
||||
| { kind: 'commit-rename' }
|
||||
| { kind: 'cancel-rename' }
|
||||
| { kind: 'pass' }
|
||||
|
||||
const PASS: SessionPickerAction = { kind: 'pass' }
|
||||
|
||||
/**
|
||||
* Route one key press. While RENAMING, the rename input owns every key except
|
||||
* Enter (commit) and Esc/Ctrl+C (cancel rename — NOT close). Otherwise:
|
||||
* Esc/Ctrl+C close, Enter resumes, ↑↓ (or Ctrl+P/N) move, Tab/Shift+Tab cycle
|
||||
* tabs, ←/→ cycle only on an empty query (with text they stay cursor moves),
|
||||
* Space toggles the preview (it never types — fuzzy terms don't need literal
|
||||
* spaces), Ctrl+R starts the inline rename. Everything else belongs to the
|
||||
* focused search input.
|
||||
*/
|
||||
export function routeSessionPickerKey(
|
||||
name: string,
|
||||
mods: { ctrl?: boolean; shift?: boolean },
|
||||
ctx: SessionPickerKeyContext
|
||||
): SessionPickerAction {
|
||||
if (ctx.renaming) {
|
||||
if (name === 'return') return { kind: 'commit-rename' }
|
||||
if (name === 'escape' || (mods.ctrl && name === 'c')) return { kind: 'cancel-rename' }
|
||||
return PASS
|
||||
}
|
||||
if (name === 'escape' || (mods.ctrl && name === 'c')) return { kind: 'close' }
|
||||
if (name === 'return') return { kind: 'resume' }
|
||||
if (name === 'up' || (mods.ctrl && name === 'p')) return { kind: 'move', dir: -1 }
|
||||
if (name === 'down' || (mods.ctrl && name === 'n')) return { kind: 'move', dir: 1 }
|
||||
if (name === 'tab') return { kind: 'cycle-tab', dir: mods.shift ? -1 : 1 }
|
||||
if ((name === 'left' || name === 'right') && ctx.queryEmpty) {
|
||||
return { kind: 'cycle-tab', dir: name === 'left' ? -1 : 1 }
|
||||
}
|
||||
if (name === 'space') return { kind: 'preview' }
|
||||
if (mods.ctrl && name === 'r') return { kind: 'rename' }
|
||||
return PASS
|
||||
}
|
||||
|
||||
// ── relative time + row meta ──────────────────────────────────────────────
|
||||
|
||||
/** Epoch seconds OR milliseconds → ms (DB rows are seconds; be lenient). */
|
||||
function toMs(epoch: number): number {
|
||||
return epoch >= 1e12 ? epoch : epoch * 1000
|
||||
}
|
||||
|
||||
const TIME_STEPS: ReadonlyArray<{ ms: number; unit: string }> = [
|
||||
{ ms: 60_000, unit: 'minute' },
|
||||
{ ms: 3_600_000, unit: 'hour' },
|
||||
{ ms: 86_400_000, unit: 'day' },
|
||||
{ ms: 604_800_000, unit: 'week' },
|
||||
{ ms: 2_629_800_000, unit: 'month' },
|
||||
{ ms: 31_557_600_000, unit: 'year' }
|
||||
]
|
||||
|
||||
/** "just now" / "1 minute ago" / "5 hours ago" / "2 weeks ago" … */
|
||||
export function relativeTime(epoch: number | undefined, nowMs: number): string {
|
||||
if (!epoch) return 'unknown'
|
||||
const delta = nowMs - toMs(epoch)
|
||||
if (delta < 60_000) return 'just now'
|
||||
for (let i = TIME_STEPS.length - 1; i >= 0; i--) {
|
||||
const step = TIME_STEPS[i]
|
||||
if (step && delta >= step.ms) {
|
||||
const n = Math.floor(delta / step.ms)
|
||||
return `${n} ${step.unit}${n === 1 ? '' : 's'} ago`
|
||||
}
|
||||
}
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
/** Tail-truncate a path-ish string to `max` chars (`…tail/of/path`). */
|
||||
export function tailTruncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text
|
||||
return `…${text.slice(text.length - (max - 1))}`
|
||||
}
|
||||
|
||||
/** Max cwd tail shown in a row's meta line. */
|
||||
const META_CWD_MAX = 40
|
||||
|
||||
/** Row meta line: relative time · source · N msgs · cwd (when present). */
|
||||
export function rowMeta(row: SessionRow, nowMs: number): string {
|
||||
const parts = [
|
||||
relativeTime(row.lastActive || row.startedAt, nowMs),
|
||||
row.source || 'unknown',
|
||||
`${row.messageCount} msgs`
|
||||
]
|
||||
if (row.cwd) parts.push(tailTruncate(row.cwd, META_CWD_MAX))
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// ── slash entry points ────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a `/sessions <tab>` argument (case-insensitive, singular tolerated;
|
||||
* bare/empty → the default Recent tab). Garbage → undefined (usage notice). */
|
||||
export function parseSessionTabArg(arg: string): SessionTabId | undefined {
|
||||
const a = arg.trim().toLowerCase()
|
||||
if (!a || a === 'recent') return 'recent'
|
||||
if (a === 'cron') return 'cron'
|
||||
if (a === 'gateway' || a === 'gateways') return 'gateways'
|
||||
if (a === 'all') return 'all'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `/resume <id|name>` argument against listed rows (the direct
|
||||
* path): exact id → unique id prefix → exact title (case-insensitive) →
|
||||
* unique case-insensitive title substring. Ambiguous/missing → undefined.
|
||||
*/
|
||||
export function resolveSessionArg(rows: readonly SessionRow[], arg: string): SessionRow | undefined {
|
||||
const needle = arg.trim()
|
||||
if (!needle) return undefined
|
||||
const exactId = rows.find(r => r.id === needle)
|
||||
if (exactId) return exactId
|
||||
const idPrefix = rows.filter(r => r.id.startsWith(needle))
|
||||
if (idPrefix.length === 1) return idPrefix[0]
|
||||
const lower = needle.toLowerCase()
|
||||
const exactTitle = rows.filter(r => r.title.toLowerCase() === lower)
|
||||
if (exactTitle.length === 1) return exactTitle[0]
|
||||
const sub = rows.filter(r => r.title.toLowerCase().includes(lower))
|
||||
if (sub.length === 1) return sub[0]
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Slash-token matching for the composer (Epic 6) — pure tokenizer + matcher,
|
||||
* no deps, fully table-testable. The composer uses this to:
|
||||
*
|
||||
* 1. HIGHLIGHT a `/name` token whose name exactly matches a valid
|
||||
* command/skill name (native textarea highlight ranges),
|
||||
* 2. SUGGEST an autocorrect when the message IS a bare `/name` token at the
|
||||
* very start and the name is exactly one edit away (Damerau-Levenshtein /
|
||||
* OSA distance 1) from exactly ONE valid name — surfaced through the
|
||||
* existing completion dropdown, never auto-applied.
|
||||
*
|
||||
* Anti-jank rule (the whole point): a `/` in the middle of prose must NOT
|
||||
* trigger completion or autocorrect. Mid-prose tokens get highlight-only when
|
||||
* they exactly match a valid name; otherwise nothing happens. Path-looking
|
||||
* tokens (`a/b`, `/usr/bin`, `./x`) are never tokens at all.
|
||||
*
|
||||
* The catalog of valid names is supplied by the caller (the composer LEARNS it
|
||||
* from the slash-completion batches the gateway already sends — the completion
|
||||
* flow is the source of truth; nothing is hardcoded here).
|
||||
*/
|
||||
|
||||
/** A standalone `/name` token found in the composer text. */
|
||||
export interface SlashToken {
|
||||
/** The name WITHOUT the leading `/`. */
|
||||
name: string
|
||||
/** Char offset of the leading `/` in the text. */
|
||||
start: number
|
||||
/** Char offset one past the last name char. */
|
||||
end: number
|
||||
/** Whether the token sits at the very start of the message (offset 0). */
|
||||
lead: boolean
|
||||
}
|
||||
|
||||
/** An autocorrect suggestion for the lead token (`/comit` → `commit`). */
|
||||
export interface SlashSuggestion {
|
||||
/** The corrected name (no slash). */
|
||||
name: string
|
||||
/** Char offset the accepted suggestion replaces from (just past the `/`). */
|
||||
from: number
|
||||
}
|
||||
|
||||
export interface SlashAnalysis {
|
||||
/** Tokens whose name EXACTLY matches a valid name — highlight these. */
|
||||
highlights: SlashToken[]
|
||||
/** The one-edit autocorrect for a bare lead token; null when none applies. */
|
||||
suggestion: SlashSuggestion | null
|
||||
}
|
||||
|
||||
/** Command/skill name charset: starts alphanumeric, then word chars / `.` / `-`.
|
||||
* Notably EXCLUDES `/` — `/usr/bin` is a path, never a command token. */
|
||||
const NAME_RE = /^[A-Za-z0-9][\w.-]*$/
|
||||
|
||||
const isSpace = (ch: string | undefined): boolean => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
|
||||
|
||||
/**
|
||||
* Extract every standalone `/name` token. Boundary rules:
|
||||
* - the `/` must be at the start of the text or preceded by whitespace
|
||||
* (`a/b` and `path/to` are not tokens),
|
||||
* - the name runs to the next whitespace (or end) and must match NAME_RE
|
||||
* (`/usr/bin` has a `/` in the body → not a token; bare `/` is nothing).
|
||||
*/
|
||||
export function slashTokens(text: string): SlashToken[] {
|
||||
const tokens: SlashToken[] = []
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== '/') continue
|
||||
if (i > 0 && !isSpace(text[i - 1])) continue
|
||||
let j = i + 1
|
||||
while (j < text.length && !isSpace(text[j])) j++
|
||||
const name = text.slice(i + 1, j)
|
||||
if (NAME_RE.test(name)) tokens.push({ end: j, lead: i === 0, name, start: i })
|
||||
i = j
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `a` and `b` are EXACTLY one edit apart under Damerau-Levenshtein
|
||||
* (OSA): one substitution, one insertion/deletion, or one adjacent
|
||||
* transposition. Equal strings are zero edits → false.
|
||||
*/
|
||||
export function isOneEdit(a: string, b: string): boolean {
|
||||
if (a === b) return false
|
||||
const la = a.length
|
||||
const lb = b.length
|
||||
if (Math.abs(la - lb) > 1) return false
|
||||
if (la === lb) {
|
||||
// one substitution, or one adjacent transposition
|
||||
let i = 0
|
||||
while (i < la && a[i] === b[i]) i++
|
||||
if (i === la) return false // identical (handled above, defensive)
|
||||
// try substitution: rest after i must match
|
||||
if (a.slice(i + 1) === b.slice(i + 1)) return true
|
||||
// try transposition of i,i+1
|
||||
return i + 1 < la && a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2)
|
||||
}
|
||||
// one insertion/deletion: align the longer against the shorter
|
||||
const [short, long] = la < lb ? [a, b] : [b, a]
|
||||
let i = 0
|
||||
while (i < short.length && short[i] === long[i]) i++
|
||||
return short.slice(i) === long.slice(i + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze the composer text against the valid-name catalog.
|
||||
* Matching is CASE-SENSITIVE per the catalog (commands are stored lowercase;
|
||||
* `/Help` is not exact — though it IS one edit from `help`, so it suggests).
|
||||
*
|
||||
* Suggestion rules (anti-jank):
|
||||
* - only for the LEAD token, and only while the message is EXACTLY the bare
|
||||
* token (`/comit` — not `/comit args`, never mid-prose),
|
||||
* - the token must not already be exact,
|
||||
* - exactly ONE catalog name within one edit; ambiguity → nothing.
|
||||
*/
|
||||
export function analyzeSlash(text: string, names: ReadonlySet<string>): SlashAnalysis {
|
||||
const tokens = slashTokens(text)
|
||||
const highlights = tokens.filter(t => names.has(t.name))
|
||||
let suggestion: SlashSuggestion | null = null
|
||||
const lead = tokens[0]
|
||||
if (lead && lead.lead && text === `/${lead.name}` && !names.has(lead.name)) {
|
||||
let candidate: string | undefined
|
||||
let count = 0
|
||||
for (const n of names) {
|
||||
if (isOneEdit(lead.name, n)) {
|
||||
candidate = n
|
||||
if (++count > 1) break
|
||||
}
|
||||
}
|
||||
if (count === 1 && candidate !== undefined) suggestion = { from: 1, name: candidate }
|
||||
}
|
||||
return { highlights, suggestion }
|
||||
}
|
||||
|
||||
/**
|
||||
* Names learnable from a slash-completion batch. Only when the composer text is
|
||||
* a bare lead token (`/…` with no space) are the candidates command/skill NAMES
|
||||
* — after a space the gateway completes ARGS (`/details thinking`), which must
|
||||
* not pollute the catalog. Item text arrives as `name `, `name`, or `/name`
|
||||
* (the gateway's extras carry the slash); all normalize to the bare name.
|
||||
*/
|
||||
export function learnableNames(text: string, items: ReadonlyArray<{ text: string }>): string[] {
|
||||
if (!/^\/\S*$/.test(text)) return []
|
||||
const out: string[] = []
|
||||
for (const item of items) {
|
||||
let name = item.text.trim()
|
||||
if (name.startsWith('/')) name = name.slice(1)
|
||||
if (NAME_RE.test(name)) out.push(name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a JS string offset into the native highlight char offset: the native
|
||||
* char-range counter skips newlines (mirror of ExtmarksController.
|
||||
* offsetExcludingNewlines in @opentui/core for plain-width text).
|
||||
*/
|
||||
export function nativeCharOffset(text: string, offset: number): number {
|
||||
let newlines = 0
|
||||
const max = Math.min(offset, text.length)
|
||||
for (let i = 0; i < max; i++) if (text[i] === '\n') newlines++
|
||||
return offset - newlines
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
/**
|
||||
* Slash command system — the SOLID side (spec §1; mirrors Ink
|
||||
* `app/createSlashHandler.ts` + `domain/slash.ts`). Plain functions/data, NOT
|
||||
* Effect; the boundary injects a Promise-returning `request` so dispatch can call
|
||||
* `slash.exec` / `command.dispatch` / `commands.catalog`.
|
||||
*
|
||||
* Dispatch ladder (Ink parity):
|
||||
* 1. client-local command (the TUI-only set — handled in-process)
|
||||
* 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line
|
||||
* 3. on reject → `command.dispatch {arg, name, session_id}` → typed action
|
||||
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
||||
* prefill → notice). Long output routes to the pager (Phase 5a).
|
||||
*/
|
||||
import { diagnosticsEnabled } from './env.ts'
|
||||
import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts'
|
||||
import { formatBytes, memReport, performHeapdump } from './diagnostics.ts'
|
||||
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts'
|
||||
import { mapSessionRows, parseSessionTabArg, resolveSessionArg, type SessionTabId } from './sessionPicker.ts'
|
||||
import type { CompletionItem, PickerItem, PickerState } from './store.ts'
|
||||
|
||||
export interface ParsedSlash {
|
||||
name: string
|
||||
arg: string
|
||||
}
|
||||
|
||||
/** Parse `/name rest…` → {name, arg}; null if not a slash command. */
|
||||
export function parseSlash(input: string): ParsedSlash | null {
|
||||
if (!input.startsWith('/')) return null
|
||||
const body = input.slice(1).trimStart()
|
||||
if (!body) return null
|
||||
const sp = body.indexOf(' ')
|
||||
return sp === -1 ? { arg: '', name: body } : { arg: body.slice(sp + 1).trim(), name: body.slice(0, sp) }
|
||||
}
|
||||
|
||||
/** How a submitted composer line is routed (F9 + slash ladder): a `!cmd` runs a
|
||||
* shell command, a `/command` goes through the slash dispatcher, everything else
|
||||
* is a prompt turn. `payload` is the command (shell) with the lead `!` stripped
|
||||
* and trimmed, or the original text (slash/prompt). */
|
||||
export type SubmitRoute =
|
||||
| { kind: 'shell'; payload: string }
|
||||
| { kind: 'slash'; payload: string }
|
||||
| { kind: 'prompt'; payload: string }
|
||||
|
||||
export function classifySubmit(text: string): SubmitRoute {
|
||||
if (text.startsWith('!')) return { kind: 'shell', payload: text.slice(1).trim() }
|
||||
if (text.startsWith('/')) return { kind: 'slash', payload: text }
|
||||
return { kind: 'prompt', payload: text }
|
||||
}
|
||||
|
||||
/** The host capabilities the dispatcher needs (wired by the entry boundary). */
|
||||
export interface SlashContext {
|
||||
/** Server RPC (resolves with the result, rejects on GatewayError). */
|
||||
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
readonly sessionId: () => string | undefined
|
||||
readonly pushSystem: (text: string) => void
|
||||
/** Open the full-screen pager (long output: /status, /logs, …). */
|
||||
readonly openPager: (title: string, text: string) => void
|
||||
/** Submit a user turn (skill/send dispatch results). */
|
||||
readonly submit: (text: string) => void
|
||||
/** Open a local Y/N confirm; `onConfirm` runs on Yes. */
|
||||
readonly confirm: (message: string, onConfirm: () => void) => void
|
||||
readonly clearTranscript: () => void
|
||||
/** Copy the n-th newest assistant response to the clipboard; returns whether something was copied. */
|
||||
readonly copyResponse: (n: number) => boolean
|
||||
readonly quit: () => void
|
||||
/** Recent log lines for `/logs` (the ring buffer). */
|
||||
readonly logTail: () => string[]
|
||||
/** Open the tabbed resume picker on the given tab (/sessions, bare /resume). */
|
||||
readonly openSessionPicker: (tab: SessionTabId) => void
|
||||
/** Resume a session directly by id (`/resume <id|name>` — no picker). */
|
||||
readonly resumeSession: (sessionId: string) => void
|
||||
/** Open a generic picker (model picker, skills hub). */
|
||||
readonly openPicker: (picker: PickerState) => void
|
||||
/** Open the agents dashboard (/agents, /tasks). */
|
||||
readonly openDashboard: () => void
|
||||
/** Open the OS background-process panel (/processes). */
|
||||
readonly openBackgroundPanel: () => void
|
||||
/** Track an in-flight background-prompt task id (`/bg` → prompt.background). */
|
||||
readonly addBgTask: (id: string) => void
|
||||
/** Cached `/model` picker rows (Epic 7 instant open); undefined until prefetched. */
|
||||
readonly modelItems: () => PickerItem[] | undefined
|
||||
/** Update the cached `/model` picker rows. */
|
||||
readonly setModelItems: (items: PickerItem[]) => void
|
||||
/** Read / set the compact-transcript display flag (/compact — Epic 3). */
|
||||
readonly compact: () => boolean
|
||||
readonly setCompact: (on: boolean) => void
|
||||
/** Read / set the global tool/reasoning detail mode (/details — Epic 3). */
|
||||
readonly details: () => DetailsMode
|
||||
readonly setDetails: (mode: DetailsMode) => void
|
||||
/** Mounted-renderable count under the live renderer root (a /mem diagnostic);
|
||||
* undefined when no renderer is reachable (tests). */
|
||||
readonly renderableCount: () => number | undefined
|
||||
}
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
/** A planned completion query (item 5/13): which RPC + params, and where an
|
||||
* accepted item replaces from if the RPC omits its own `replace_from`. */
|
||||
export interface CompletionPlan {
|
||||
method: 'complete.slash' | 'complete.path'
|
||||
params: Record<string, unknown>
|
||||
from: number
|
||||
}
|
||||
|
||||
/** The command-name grammar for the lead `/token` (mirrors skillMatch NAME_RE):
|
||||
* starts alphanumeric, then word chars / `.` / `-`. Notably EXCLUDES `/`, so a
|
||||
* path like `/usr/bin` is NEVER a slash command (F2). */
|
||||
const SLASH_NAME_RE = /^[A-Za-z0-9][\w.-]*$/
|
||||
|
||||
/** `@`-mention is the ONLY file/dir completion trigger now (F8b — glitch
|
||||
* 2026-06-13: drop `~`/`./`/`/`/bare-path as triggers; the gateway's
|
||||
* complete.path still understands `@file:`/`@folder:`/fuzzy basename). */
|
||||
function isPathLike(word: string): boolean {
|
||||
return word.startsWith('@')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to complete for the composer text + cursor offset:
|
||||
* - the text is a slash command — `/` at the very start → `complete.slash
|
||||
* {text}`. A bare `/` opens the full command list immediately (glitch
|
||||
* 2026-06-13); `/m`, `/model foo` narrow it. A `/abs/path` whose first token
|
||||
* isn't a valid name (F2) → no slash menu.
|
||||
* - the WORD under the cursor is an `@`-mention → `complete.path {word}` for
|
||||
* file/dir tagging (F8b).
|
||||
* - otherwise nothing.
|
||||
*
|
||||
* Cursor-aware (F7/F8): completion is computed from the line/token at the cursor,
|
||||
* so it keeps working on later lines after Shift+Enter (the old whole-buffer
|
||||
* `includes('\n')` bail killed it on every multi-line buffer). `cursor` defaults
|
||||
* to the end of `text`. Slash commands stay first-line-only (a `/` mid-buffer is
|
||||
* prose, never a command).
|
||||
* Returns null when there's no completion to run (so the dropdown clears).
|
||||
*/
|
||||
export function planCompletion(text: string, cursor: number = text.length): CompletionPlan | null {
|
||||
// Slash command: only when the WHOLE buffer's lead token is a command. A `/`
|
||||
// after a newline is prose, so a slash command never spans lines.
|
||||
if (text.startsWith('/') && !text.includes('\n')) {
|
||||
const body = text.slice(1)
|
||||
const space = body.search(/\s/)
|
||||
const name = space === -1 ? body : body.slice(0, space)
|
||||
// Hydrate on a BARE `/` (body === '', glitch 2026-06-13 — open the full
|
||||
// command list on the first slash) or a valid command name. A `/abs/path`
|
||||
// (the lead token contains a `/`) is never a command (F2), and a `/ ` with a
|
||||
// trailing space past an empty name is not arg-completion on nothing.
|
||||
if (body === '' || SLASH_NAME_RE.test(name)) {
|
||||
return { from: 0, method: 'complete.slash', params: { text } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
// @-mention: the whitespace-delimited token the cursor sits in/just after.
|
||||
const pos = Math.max(0, Math.min(cursor, text.length))
|
||||
const head = text.slice(0, pos)
|
||||
const tokenStart = head.search(/\S+$/)
|
||||
if (tokenStart === -1) return null
|
||||
const word = head.slice(tokenStart)
|
||||
if (isPathLike(word)) {
|
||||
return { from: tokenStart, method: 'complete.path', params: { word } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Read a `replace_from` offset off a completion result, falling back to `fallback`. */
|
||||
export function readReplaceFrom(result: unknown, fallback: number): number {
|
||||
if (result && typeof result === 'object') {
|
||||
const rf = (result as { replace_from?: unknown }).replace_from
|
||||
if (typeof rf === 'number') return rf
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Map a `complete.slash`/`complete.path` result ({items:[{text,display,meta}]}) into candidates. */
|
||||
export function mapCompletions(result: unknown): CompletionItem[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const items = (result as { items?: unknown }).items
|
||||
if (!Array.isArray(items)) return []
|
||||
const out: CompletionItem[] = []
|
||||
for (const it of items) {
|
||||
const text = readStr(it, 'text')
|
||||
if (!text) continue
|
||||
out.push({ display: readStr(it, 'display') ?? text, meta: readStr(it, 'meta') ?? '', text })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Extract `{text}` items from a `commands.catalog` result ({pairs:[["/name",
|
||||
* "desc"],…]}) for seeding the composer's slash-highlight catalog at boot
|
||||
* (glitch 2026-06-14). Each pair's first element is the `/name`; non-string or
|
||||
* empty entries are skipped. Shape-defensive — any junk → []. */
|
||||
export function catalogCommandItems(result: unknown): { text: string }[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const pairs = (result as { pairs?: unknown }).pairs
|
||||
if (!Array.isArray(pairs)) return []
|
||||
const out: { text: string }[] = []
|
||||
for (const pair of pairs as unknown[]) {
|
||||
const name = Array.isArray(pair) ? (pair as unknown[])[0] : undefined
|
||||
if (typeof name === 'string' && name) out.push({ text: name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A monotonic gate for the per-keystroke completion RPCs (glitch 2026-06-14).
|
||||
* The gateway transport does NOT guarantee in-order response delivery and
|
||||
* `onType` fires an RPC per keystroke with no debounce, so a slow earlier query
|
||||
* (the first bare-`/` `complete.slash`) can resolve AFTER a newer one (an
|
||||
* `@`-mention `complete.path`) and clobber the store with stale results — which
|
||||
* is what made "a leading /path message breaks @-mentions afterward."
|
||||
*
|
||||
* `claim()` is called once per keystroke (BEFORE the early-return clear branch,
|
||||
* so an intermediate keystroke that fires no RPC still invalidates the older
|
||||
* in-flight one) and returns a token; `isCurrent(token)` is true only for the
|
||||
* most recently claimed token, so a resolving response applies ONLY when no
|
||||
* newer keystroke has superseded it.
|
||||
*/
|
||||
export interface CompletionGate {
|
||||
claim: () => number
|
||||
isCurrent: (token: number) => boolean
|
||||
}
|
||||
|
||||
export function createCompletionGate(): CompletionGate {
|
||||
let seq = 0
|
||||
return {
|
||||
claim: () => ++seq,
|
||||
isCurrent: (token: number) => token === seq
|
||||
}
|
||||
}
|
||||
|
||||
/** Long output → the pager; short → a system line (Ink: >180 chars or >2 lines). */
|
||||
function present(ctx: SlashContext, title: string, text: string): void {
|
||||
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
||||
if (long) ctx.openPager(title, text)
|
||||
else ctx.pushSystem(text)
|
||||
}
|
||||
|
||||
/** Process-diagnostic commands — hidden behind `HERMES_TUI_DIAGNOSTICS`
|
||||
* (logic/env.ts). Regular users never see them; support flows enable them
|
||||
* with one env var. Keep this set in sync with the `(diag)` lines below.
|
||||
* DESIGN ASSUMPTION (review 2026-06-12): these stay CLIENT-ONLY. Completion
|
||||
* is gateway-driven and hides them only because the gateway doesn't know
|
||||
* them — adding a server command with one of these names requires gating it
|
||||
* gateway-side too (the early return below would shadow, not hide, it). */
|
||||
const DIAGNOSTIC_COMMANDS = new Set(['mem', 'heapdump'])
|
||||
|
||||
const CLIENT_HELP_LINES = [
|
||||
'/help — list commands',
|
||||
'/model [name] — switch model (picker if bare)',
|
||||
'/copy [n] — copy the last (or n-th) response',
|
||||
'/skills — browse skills',
|
||||
'/skin [name] — switch theme skin (live)',
|
||||
'/sessions [cron|gateways|all] — browse/resume sessions (tabbed picker)',
|
||||
'/resume [id|name] — resume directly, or open the picker',
|
||||
'/clear, /new — clear the transcript (confirm)',
|
||||
'/compact [on|off|toggle] — compact transcript spacing',
|
||||
'/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail',
|
||||
'/bg <prompt> — launch a background prompt',
|
||||
'/processes — OS background processes (list + stop all)',
|
||||
'/replay [n|path] — inspect an archived spawn tree',
|
||||
'/mem — live memory stats (diag)',
|
||||
'/heapdump — write a V8 heap snapshot (diag)',
|
||||
'/logs — recent engine log lines',
|
||||
'/quit, /exit — quit',
|
||||
'(other /commands run on the gateway)'
|
||||
]
|
||||
|
||||
function clientHelp(): string {
|
||||
const lines = diagnosticsEnabled() ? CLIENT_HELP_LINES : CLIENT_HELP_LINES.filter(l => !l.includes('(diag)'))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
||||
|
||||
/** `/sessions [recent|cron|gateways|all]` — open the tabbed resume picker,
|
||||
* pre-selecting the named tab (shared by /sessions, /switch, /session). */
|
||||
const sessionsCmd: ClientHandler = (arg, ctx) => {
|
||||
const tab = parseSessionTabArg(arg)
|
||||
if (!tab) {
|
||||
ctx.pushSystem('usage: /sessions [recent|cron|gateways|all]')
|
||||
return
|
||||
}
|
||||
ctx.openSessionPicker(tab)
|
||||
}
|
||||
|
||||
/** `/resume` — bare opens the picker; `/resume <id|name>` keeps the DIRECT
|
||||
* path: resolve the arg against `session.list` (exact id → unique id prefix
|
||||
* → exact/unique title) and hydrate without the overlay. */
|
||||
const resumeCmd: ClientHandler = async (arg, ctx) => {
|
||||
const needle = arg.trim()
|
||||
if (!needle) {
|
||||
ctx.openSessionPicker('recent')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// One bounded page over ALL sources (the gateway deny-lists `tool`) — the
|
||||
// direct path targets a known session, not a browse.
|
||||
const { rows } = mapSessionRows(await ctx.request('session.list', { limit: 200 }))
|
||||
const hit = resolveSessionArg(rows, needle)
|
||||
if (!hit) {
|
||||
ctx.pushSystem(`/resume: no session matching “${needle}” — try /sessions`)
|
||||
return
|
||||
}
|
||||
ctx.resumeSession(hit.id)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/resume: ${error instanceof Error ? error.message : 'session.list failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten `model.options` into grouped picker rows (Epic 7; v2.1 availability):
|
||||
* group = the provider's display ("lab") name, haystacks = slug + lab name (so
|
||||
* `oai`/`copilot`/`anthropic` fuzzy-match the whole group), value = the FULL
|
||||
* switch arg `<model> --provider <slug>` so picking a model under a different
|
||||
* provider actually switches provider+model (the gateway's
|
||||
* `_apply_model_switch` parses `--provider` via parse_model_flags). The current
|
||||
* model is flagged, not baked into the label, so the fuzzy scorer never matches
|
||||
* the ✓.
|
||||
*
|
||||
* UNCONFIGURED providers (`authenticated: false` skeleton rows — the gateway
|
||||
* sends them via `build_models_payload(include_unconfigured=True,
|
||||
* picker_hints=True)`, with `key_env`/`warning` setup hints) become one
|
||||
* `unavailable` hint row each (`no API key — set <ENV_VAR>`): hidden by
|
||||
* default, revealed dimmed + non-selectable by the picker's Ctrl+U toggle.
|
||||
*/
|
||||
export function mapModelOptions(opts: unknown): PickerItem[] {
|
||||
if (!opts || typeof opts !== 'object') return []
|
||||
const providers = (opts as { providers?: unknown }).providers
|
||||
if (!Array.isArray(providers)) return []
|
||||
const current = readStr(opts, 'model')
|
||||
const currentProvider = readStr(opts, 'provider')
|
||||
const items: PickerItem[] = []
|
||||
for (const p of providers) {
|
||||
if (!p || typeof p !== 'object') continue
|
||||
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
|
||||
const lab = readStr(p, 'name') ?? slug
|
||||
if ((p as { authenticated?: unknown }).authenticated === false) {
|
||||
// Unconfigured provider → one dimmed hint row under its own group header.
|
||||
// Identity (slug + display name) is the haystack so a provider-name query
|
||||
// still narrows to the group; the hint text itself is not searched.
|
||||
const keyEnv = readStr(p, 'key_env')
|
||||
const item: PickerItem = {
|
||||
group: lab || slug,
|
||||
label: keyEnv ? `no API key — set ${keyEnv}` : (readStr(p, 'warning') ?? 'not configured'),
|
||||
unavailable: true,
|
||||
value: slug || lab
|
||||
}
|
||||
const hay = [slug, lab].filter(Boolean)
|
||||
if (hay.length) item.haystacks = hay
|
||||
items.push(item)
|
||||
continue
|
||||
}
|
||||
if ((p as { authenticated?: unknown }).authenticated !== true) continue
|
||||
// The gateway's own normalized "this row is the active provider" flag —
|
||||
// more reliable than comparing `provider` to `slug` (the agent's provider
|
||||
// string can be the API dialect, e.g. an openai-compatible base_url).
|
||||
const rowCurrent = (p as { is_current?: unknown }).is_current === true
|
||||
const models = (p as { models?: unknown }).models
|
||||
if (!Array.isArray(models)) continue
|
||||
for (const m of models) {
|
||||
if (typeof m !== 'string') continue
|
||||
const item: PickerItem = { label: m, value: slug ? `${m} --provider ${slug}` : m }
|
||||
// current = same model id under the active provider (row flag first,
|
||||
// then the slug comparison, then "no provider known at all").
|
||||
if (m === current && (rowCurrent || currentProvider === slug || !currentProvider)) item.current = true
|
||||
if (lab) item.group = lab
|
||||
const haystacks = [slug, lab].filter(Boolean)
|
||||
if (haystacks.length) item.haystacks = haystacks
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
// Provider matching failed entirely (string-normalization drift) but the
|
||||
// model id is known → flag the first id match so the ✓ never just vanishes.
|
||||
if (current && !items.some(i => i.current)) {
|
||||
const fallback = items.find(i => i.label === current)
|
||||
if (fallback) fallback.current = true
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider tab order for the model picker's chip strip (picker v2.2): each
|
||||
* CONFIGURED provider's group (= lab display name) in catalog order, with
|
||||
* Nous-identified groups (slug or lab name containing `nous`) hoisted to the
|
||||
* front. Unconfigured providers (`unavailable` hint rows) get NO tab — they
|
||||
* stay reachable via Ctrl+U under the picker's trailing `All` tab (which the
|
||||
* picker appends itself; it is not part of this list).
|
||||
*/
|
||||
export function buildModelTabs(items: readonly PickerItem[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const nous: string[] = []
|
||||
const rest: string[] = []
|
||||
for (const it of items) {
|
||||
if (it.unavailable || !it.group || seen.has(it.group)) continue
|
||||
seen.add(it.group)
|
||||
const identity = [it.group, ...(it.haystacks ?? [])].join(' ').toLowerCase()
|
||||
;(identity.includes('nous') ? nous : rest).push(it.group)
|
||||
}
|
||||
return [...nous, ...rest]
|
||||
}
|
||||
|
||||
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into
|
||||
* grouped picker rows (category = group header; also a fuzzy haystack). */
|
||||
function mapSkills(result: unknown): PickerItem[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const skills = (result as { skills?: unknown }).skills
|
||||
if (!skills || typeof skills !== 'object') return []
|
||||
const items: PickerItem[] = []
|
||||
for (const [category, names] of Object.entries(skills as { [k: string]: unknown })) {
|
||||
if (!Array.isArray(names)) continue
|
||||
for (const n of names) if (typeof n === 'string') items.push({ group: category, label: n, value: n })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** Re-fetch `model.options` and update the cached picker rows. Resolves with
|
||||
* the fresh rows (the open picker swaps them in live — Ctrl+R, picker v2.1);
|
||||
* rejections are the CALLER's to handle (background callers fire-and-forget). */
|
||||
function refreshModelItems(ctx: SlashContext): Promise<PickerItem[]> {
|
||||
return ctx.request('model.options', { session_id: ctx.sessionId() }).then(opts => {
|
||||
const items = mapModelOptions(opts)
|
||||
if (items.length) ctx.setModelItems(items)
|
||||
return items
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The open picker's manual-refresh seam (picker v2.1 Ctrl+R). Whoever opens a
|
||||
* picker registers (or clears) the catalog re-fetch here; the mounted Picker
|
||||
* triggers it via `runPickerRefresh` and swaps in the resolved rows live. A
|
||||
* module slot rather than a Picker prop because the App→Picker prop plumbing
|
||||
* carries only the PickerState basics; the seam keeps the overlay generic for
|
||||
* the upcoming resume-session picker (register a `session.list` re-fetch).
|
||||
*/
|
||||
let activePickerRefresh: (() => Promise<PickerItem[]>) | undefined
|
||||
|
||||
/** Register (or clear, with `undefined`) the open picker's catalog re-fetch. */
|
||||
export function registerPickerRefresh(fn: (() => Promise<PickerItem[]>) | undefined): void {
|
||||
activePickerRefresh = fn
|
||||
}
|
||||
|
||||
/** Whether a refresh is registered (the picker's footer hint is gated on it). */
|
||||
export function canRefreshPicker(): boolean {
|
||||
return activePickerRefresh !== undefined
|
||||
}
|
||||
|
||||
/** Run the registered catalog re-fetch; undefined when none is registered. */
|
||||
export function runPickerRefresh(): Promise<PickerItem[]> | undefined {
|
||||
return activePickerRefresh?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* The open picker's tab-strip seam (picker v2.2 provider tabs) — same pattern
|
||||
* as the refresh seam above: whoever opens a picker registers (or clears) a
|
||||
* tab DERIVATION over the picker's live rows; the mounted Picker re-derives
|
||||
* through it whenever the rows swap (Ctrl+R), so fresh providers grow chips
|
||||
* without re-opening. `/model` registers `buildModelTabs`; pickers without
|
||||
* tabs (skills) clear it and render the classic stripless view.
|
||||
*/
|
||||
let activePickerTabs: ((items: readonly PickerItem[]) => string[]) | undefined
|
||||
|
||||
/** Register (or clear, with `undefined`) the open picker's tab derivation. */
|
||||
export function registerPickerTabs(fn: ((items: readonly PickerItem[]) => string[]) | undefined): void {
|
||||
activePickerTabs = fn
|
||||
}
|
||||
|
||||
/** Derive the open picker's tabs from its rows; [] when no tabs are registered. */
|
||||
export function pickerTabs(items: readonly PickerItem[]): string[] {
|
||||
return activePickerTabs?.(items) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* The bootstrap `model.options` prefetch seam (perf: prefetch dedupe). The
|
||||
* entry stashes its in-flight prefetch promise here; a bare `/model` that
|
||||
* finds the cache empty AWAITS it (bounded by `waitMs`) and re-checks the
|
||||
* cache instead of issuing a second concurrent `model.options` RPC. A hung
|
||||
* prefetch only delays the picker by the bound — `/model` then opens via its
|
||||
* own fetch as before.
|
||||
*/
|
||||
let modelPrefetch: { promise: Promise<unknown>; waitMs: number } | undefined
|
||||
|
||||
/** Register (or clear, with `undefined`) the in-flight bootstrap prefetch. */
|
||||
export function registerModelPrefetch(promise: Promise<unknown> | undefined, waitMs = 2000): void {
|
||||
modelPrefetch = promise ? { promise, waitMs } : undefined
|
||||
}
|
||||
|
||||
/** Await the registered prefetch (bounded); resolves immediately when none. */
|
||||
function awaitModelPrefetch(): Promise<void> {
|
||||
const pending = modelPrefetch
|
||||
if (!pending) return Promise.resolve()
|
||||
return Promise.race([pending.promise, new Promise(resolve => setTimeout(resolve, pending.waitMs))]).then(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Switch the model via the server (shared by `/model <name>` and the picker pick).
|
||||
* A successful switch refreshes the cached rows in the background (fresh ✓). */
|
||||
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
|
||||
try {
|
||||
const r = await ctx.request('slash.exec', { command: `model ${name}`, session_id: ctx.sessionId() })
|
||||
ctx.pushSystem(readStr(r, 'output') || `→ ${name}`)
|
||||
void refreshModelItems(ctx).catch(() => {})
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/model` — bare opens the model picker; `/model <name>` switches directly.
|
||||
* Opens from the CACHED catalog when present — zero RPCs, same-frame paint
|
||||
* (Epic 7; the catalog is prefetched at bootstrap and refreshed on switch).
|
||||
* An empty cache first awaits the in-flight bootstrap prefetch (bounded) so
|
||||
* an early `/model` never doubles the slow `model.options` RPC. */
|
||||
const modelCmd: ClientHandler = async (arg, ctx) => {
|
||||
if (arg.trim()) {
|
||||
await switchModel(ctx, arg.trim())
|
||||
return
|
||||
}
|
||||
const open = (items: PickerItem[]) => {
|
||||
// Ctrl+R in the open picker re-fetches the catalog (and re-syncs the cache).
|
||||
registerPickerRefresh(() => refreshModelItems(ctx))
|
||||
// Provider chip strip (picker v2.2): Nous-first configured-provider tabs.
|
||||
registerPickerTabs(buildModelTabs)
|
||||
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
|
||||
}
|
||||
const cached = ctx.modelItems()
|
||||
if (cached?.length) {
|
||||
open(cached)
|
||||
return
|
||||
}
|
||||
// Cache empty but the bootstrap prefetch may be in flight — await it
|
||||
// (bounded) and re-check instead of racing a SECOND model.options RPC.
|
||||
await awaitModelPrefetch()
|
||||
const prefetched = ctx.modelItems()
|
||||
if (prefetched?.length) {
|
||||
open(prefetched)
|
||||
return
|
||||
}
|
||||
const items = mapModelOptions(await ctx.request('model.options', { session_id: ctx.sessionId() }))
|
||||
// Unavailable hint rows alone are not a usable catalog — keep the notice.
|
||||
if (!items.some(i => !i.unavailable)) {
|
||||
ctx.pushSystem('No models available (no authenticated providers).')
|
||||
return
|
||||
}
|
||||
ctx.setModelItems(items)
|
||||
open(items)
|
||||
}
|
||||
|
||||
/** `/skills` — open the skills hub; picking a skill shows its info in the pager. */
|
||||
const skillsCmd: ClientHandler = async (_arg, ctx) => {
|
||||
const items = mapSkills(await ctx.request('skills.manage', { action: 'list' }))
|
||||
if (!items.length) {
|
||||
ctx.pushSystem('No skills found.')
|
||||
return
|
||||
}
|
||||
registerPickerRefresh(undefined) // no Ctrl+R catalog re-fetch for skills (yet)
|
||||
registerPickerTabs(undefined) // no tab strip for skills — classic grouped view
|
||||
ctx.openPicker({
|
||||
items,
|
||||
onPick: name =>
|
||||
void ctx
|
||||
.request('skills.manage', { action: 'inspect', query: name })
|
||||
.then(info => ctx.openPager(`Skill: ${name}`, readStr(info, 'info') || JSON.stringify(info, null, 2)))
|
||||
.catch(() => ctx.pushSystem(`/skills: could not inspect ${name}`)),
|
||||
title: 'Skills'
|
||||
})
|
||||
}
|
||||
|
||||
/** `on`/`off`/`toggle`/bare → the next flag value; null on garbage (Ink flagFromArg). */
|
||||
function flagFromArg(arg: string, current: boolean): boolean | null {
|
||||
const mode = arg.trim().toLowerCase()
|
||||
if (!mode || mode === 'toggle') return !current
|
||||
if (mode === 'on') return true
|
||||
if (mode === 'off') return false
|
||||
return null
|
||||
}
|
||||
|
||||
/** `/compact [on|off|toggle]` — compact transcript spacing. The flag flips locally
|
||||
* (the store drives the render); persistence mirrors Ink: a fire-and-forget
|
||||
* `config.set {key:'compact'}` so the Ink TUI + future launches share the pref
|
||||
* (the gateway does NOT send the persisted value to this TUI, so each launch
|
||||
* starts off — see store.ts `compact`). */
|
||||
const compactCmd: ClientHandler = (arg, ctx) => {
|
||||
const next = flagFromArg(arg, ctx.compact())
|
||||
if (next === null) {
|
||||
ctx.pushSystem('usage: /compact [on|off|toggle]')
|
||||
return
|
||||
}
|
||||
ctx.setCompact(next)
|
||||
void ctx.request('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
|
||||
ctx.pushSystem(`compact ${next ? 'on' : 'off'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* `/details [hidden|collapsed|expanded|cycle]` — GLOBAL detail mode (per-section
|
||||
* overrides deferred; the gateway's arg completion also suggests section names,
|
||||
* so those get an honest "not supported yet" notice). Bare `/details` reports the
|
||||
* persisted mode (`config.get details_mode`) and syncs the local flag to it; a
|
||||
* mode set persists via `config.set` (fire-and-forget, Ink parity).
|
||||
*/
|
||||
const detailsCmd: ClientHandler = async (arg, ctx) => {
|
||||
const first = arg.trim().toLowerCase().split(/\s+/)[0] ?? ''
|
||||
if (!first) {
|
||||
try {
|
||||
const r = await ctx.request('config.get', { key: 'details_mode' })
|
||||
const mode = parseDetailsMode(readStr(r, 'value')) ?? ctx.details()
|
||||
ctx.setDetails(mode)
|
||||
ctx.pushSystem(`details: ${mode}`)
|
||||
} catch {
|
||||
ctx.pushSystem(`details: ${ctx.details()}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ((DETAILS_SECTIONS as readonly string[]).includes(first)) {
|
||||
ctx.pushSystem(`per-section detail overrides are not supported in the native engine yet — ${DETAILS_USAGE}`)
|
||||
return
|
||||
}
|
||||
const next = first === 'cycle' || first === 'toggle' ? nextDetailsMode(ctx.details()) : parseDetailsMode(first)
|
||||
if (!next) {
|
||||
ctx.pushSystem(DETAILS_USAGE)
|
||||
return
|
||||
}
|
||||
ctx.setDetails(next)
|
||||
void ctx.request('config.set', { key: 'details_mode', value: next }).catch(() => {})
|
||||
ctx.pushSystem(`details: ${next}`)
|
||||
}
|
||||
|
||||
/** `/skin [name]` — switch the active theme skin (Ink parity:
|
||||
* ui-tui/src/app/slash/commands/session.ts). Bare `/skin` reports the persisted
|
||||
* skin (`config.get skin`); `/skin <name>` persists via `config.set` which makes
|
||||
* the gateway emit `skin.changed` → the store re-themes the running UI LIVE (no
|
||||
* relaunch). Skin-name arg completion comes from the gateway's `complete.slash`
|
||||
* for free. Fire-and-forget with a guarded notice, matching compact/details. */
|
||||
const skinCmd: ClientHandler = async (arg, ctx) => {
|
||||
const name = arg.trim()
|
||||
if (!name) {
|
||||
try {
|
||||
const r = await ctx.request('config.get', { key: 'skin' })
|
||||
ctx.pushSystem(`skin: ${readStr(r, 'value') || 'default'}`)
|
||||
} catch {
|
||||
ctx.pushSystem('skin: default')
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await ctx.request('config.set', { key: 'skin', value: name })
|
||||
ctx.pushSystem(`skin → ${readStr(r, 'value') || name}`)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/skin: ${error instanceof Error ? error.message : 'config.set failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch + map the session's archived spawn trees (`spawn_tree.list`). */
|
||||
async function listSpawnTrees(ctx: SlashContext) {
|
||||
const r = await ctx.request('spawn_tree.list', { limit: 30, session_id: ctx.sessionId() ?? 'default' })
|
||||
return readSpawnTreeEntries(r)
|
||||
}
|
||||
|
||||
/**
|
||||
* `/replay [n|path]` — spawn-tree inspector through the pager (Ink renders these
|
||||
* in its agents overlay; the flow + RPCs are the same): bare lists the archived
|
||||
* trees with indices, `<n>` loads the n-th listed tree, anything else is treated
|
||||
* as a snapshot path on disk (`load <path>` accepted for Ink muscle memory).
|
||||
*/
|
||||
const replayCmd: ClientHandler = async (arg, ctx) => {
|
||||
const raw = arg.trim()
|
||||
const lower = raw.toLowerCase()
|
||||
try {
|
||||
if (!raw || lower === 'list' || lower === 'ls') {
|
||||
const entries = await listSpawnTrees(ctx)
|
||||
if (!entries.length) {
|
||||
ctx.pushSystem('no archived spawn trees for this session — completed delegations are archived automatically')
|
||||
return
|
||||
}
|
||||
ctx.openPager('Spawn trees', formatSpawnTreeList(entries))
|
||||
return
|
||||
}
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const n = Number.parseInt(raw, 10)
|
||||
const entries = await listSpawnTrees(ctx)
|
||||
const entry = entries[n - 1]
|
||||
if (!entry) {
|
||||
ctx.pushSystem(
|
||||
entries.length
|
||||
? `replay: index out of range 1..${entries.length} — /replay to list`
|
||||
: 'no archived spawn trees for this session'
|
||||
)
|
||||
return
|
||||
}
|
||||
const tree = await ctx.request('spawn_tree.load', { path: entry.path })
|
||||
ctx.openPager(`Replay ${n}`, formatSpawnTree(tree))
|
||||
return
|
||||
}
|
||||
const path = lower.startsWith('load ') ? raw.slice(5).trim() : raw
|
||||
const tree = await ctx.request('spawn_tree.load', { path })
|
||||
ctx.openPager('Replay', formatSpawnTree(tree))
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/replay: ${error instanceof Error ? error.message : 'failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/heapdump` — write a V8 heap snapshot to `$HERMES_HOME|~/.hermes/logs/` and
|
||||
* report the path + heap/rss before vs after (Ink ref debug.ts /heapdump). */
|
||||
const heapdumpCmd: ClientHandler = (_arg, ctx) => {
|
||||
const pre = process.memoryUsage()
|
||||
ctx.pushSystem(`writing heap dump (heap ${formatBytes(pre.heapUsed)} · rss ${formatBytes(pre.rss)})…`)
|
||||
try {
|
||||
const { after, before, path } = performHeapdump()
|
||||
ctx.pushSystem(
|
||||
`heapdump: ${path}\n` +
|
||||
`heap ${formatBytes(before.heapUsed)} → ${formatBytes(after.heapUsed)} · ` +
|
||||
`rss ${formatBytes(before.rss)} → ${formatBytes(after.rss)}`
|
||||
)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`heapdump failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/mem` — live V8 heap/rss numbers + uptime + the mounted-renderable count
|
||||
* (the store-cap diagnostic) as one system block (Ink ref debug.ts /mem). */
|
||||
const memCmd: ClientHandler = (_arg, ctx) => {
|
||||
ctx.pushSystem(memReport(process.memoryUsage(), process.uptime(), ctx.renderableCount()))
|
||||
}
|
||||
|
||||
/** `/tools` — fetch the tool roster from the gateway and show it in the pager (navigable). */
|
||||
const toolsCmd: ClientHandler = async (arg, ctx) => {
|
||||
const command = arg.trim() ? `tools ${arg.trim()}` : 'tools'
|
||||
try {
|
||||
const r = await ctx.request('slash.exec', { command, session_id: ctx.sessionId() })
|
||||
ctx.openPager('Tools', readStr(r, 'output') || '(no tool info)')
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/tools: ${error instanceof Error ? error.message : 'failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/bg <prompt>` (aliases /background, /btw) — launch a background PROMPT via
|
||||
* `prompt.background` (Ink parity): echo "bg <id> started" and track the task so
|
||||
* the `bg: N` badge counts it until `background.complete` clears it. NOT the OS
|
||||
* process panel (that's /processes). */
|
||||
const backgroundCmd: ClientHandler = async (arg, ctx) => {
|
||||
const text = arg.trim()
|
||||
if (!text) {
|
||||
ctx.pushSystem('/bg <prompt> — launch a background prompt')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await ctx.request('prompt.background', { session_id: ctx.sessionId(), text })
|
||||
const taskId = readStr(r, 'task_id')
|
||||
if (taskId) {
|
||||
ctx.addBgTask(taskId)
|
||||
ctx.pushSystem(`bg ${taskId} started`)
|
||||
} else {
|
||||
ctx.pushSystem('/bg: no task id returned')
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/bg: ${error instanceof Error ? error.message : 'failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The TUI-only client commands (run in-process, never hit the gateway). */
|
||||
const CLIENT: Record<string, ClientHandler> = {
|
||||
agents: (_arg, ctx) => ctx.openDashboard(),
|
||||
background: backgroundCmd,
|
||||
bg: backgroundCmd,
|
||||
btw: backgroundCmd,
|
||||
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
||||
compact: compactCmd,
|
||||
copy: (arg, ctx) => {
|
||||
const n = Math.max(1, Number.parseInt(arg, 10) || 1)
|
||||
if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.')
|
||||
},
|
||||
detail: detailsCmd,
|
||||
details: detailsCmd,
|
||||
exit: (_arg, ctx) => ctx.quit(),
|
||||
heapdump: heapdumpCmd,
|
||||
mem: memCmd,
|
||||
processes: (_arg, ctx) => ctx.openBackgroundPanel(),
|
||||
procs: (_arg, ctx) => ctx.openBackgroundPanel(),
|
||||
model: modelCmd,
|
||||
replay: replayCmd,
|
||||
resume: resumeCmd,
|
||||
session: sessionsCmd,
|
||||
sessions: sessionsCmd,
|
||||
skills: skillsCmd,
|
||||
skin: skinCmd,
|
||||
switch: sessionsCmd,
|
||||
tasks: (_arg, ctx) => ctx.openDashboard(),
|
||||
tools: toolsCmd,
|
||||
help: async (_arg, ctx) => {
|
||||
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
||||
try {
|
||||
const cat = await ctx.request('commands.catalog', {})
|
||||
ctx.pushSystem(renderCatalog(cat) || clientHelp())
|
||||
} catch {
|
||||
ctx.pushSystem(clientHelp())
|
||||
}
|
||||
},
|
||||
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
|
||||
new: (_arg, ctx) => ctx.confirm('Start fresh? (clears the transcript)', ctx.clearTranscript),
|
||||
quit: (_arg, ctx) => ctx.quit()
|
||||
}
|
||||
|
||||
/** The registered client-command names (catalog introspection — tests/menus). */
|
||||
export function clientCommandNames(): string[] {
|
||||
const names = Object.keys(CLIENT)
|
||||
return (diagnosticsEnabled() ? names : names.filter(n => !DIAGNOSTIC_COMMANDS.has(n))).sort()
|
||||
}
|
||||
|
||||
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
|
||||
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
|
||||
* (tui_gateway/server.py `commands.catalog`). */
|
||||
function renderCatalog(cat: unknown): string {
|
||||
if (!cat || typeof cat !== 'object') return ''
|
||||
const pairs = (cat as { pairs?: unknown }).pairs
|
||||
if (!Array.isArray(pairs)) return ''
|
||||
const lines = pairs
|
||||
.map(pair => {
|
||||
if (!Array.isArray(pair) || typeof pair[0] !== 'string') return null
|
||||
const desc = typeof pair[1] === 'string' ? pair[1] : ''
|
||||
return desc ? `${pair[0]} — ${desc}` : pair[0]
|
||||
})
|
||||
.filter((l): l is string => l !== null)
|
||||
return lines.length ? lines.join('\n') : ''
|
||||
}
|
||||
|
||||
function handleDispatchResult(parsed: ParsedSlash, raw: unknown, ctx: SlashContext): void {
|
||||
const type = readStr(raw, 'type')
|
||||
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
|
||||
switch (type) {
|
||||
case 'exec':
|
||||
case 'plugin':
|
||||
ctx.pushSystem(readStr(raw, 'output') || '(no output)')
|
||||
return
|
||||
case 'alias': {
|
||||
const target = readStr(raw, 'target')
|
||||
if (target) void dispatchSlash(`/${target}${argTail}`, ctx)
|
||||
return
|
||||
}
|
||||
case 'skill':
|
||||
case 'send': {
|
||||
const notice = readStr(raw, 'notice')
|
||||
if (notice) ctx.pushSystem(notice)
|
||||
const message = readStr(raw, 'message')
|
||||
if (message?.trim()) ctx.submit(message)
|
||||
else ctx.pushSystem(`/${parsed.name}: empty message`)
|
||||
return
|
||||
}
|
||||
case 'prefill': {
|
||||
// /undo etc. — composer prefill lands with the composer-ref plumbing; show it for now.
|
||||
const message = readStr(raw, 'message')
|
||||
ctx.pushSystem(message ? `(edit & resubmit) ${message}` : `/${parsed.name}: nothing to prefill`)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.pushSystem(`error: invalid response: command.dispatch`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a `/command` through the ladder. Returns once the (async) work settles. */
|
||||
export async function dispatchSlash(input: string, ctx: SlashContext): Promise<void> {
|
||||
const parsed = parseSlash(input)
|
||||
if (!parsed) return
|
||||
|
||||
if (DIAGNOSTIC_COMMANDS.has(parsed.name) && !diagnosticsEnabled()) {
|
||||
// Not a secret — an enable switch. Tell the user exactly how to get it.
|
||||
ctx.pushSystem(`/${parsed.name} is a diagnostic command — relaunch with HERMES_TUI_DIAGNOSTICS=1 to enable it.`)
|
||||
return
|
||||
}
|
||||
|
||||
const client = CLIENT[parsed.name]
|
||||
if (client) {
|
||||
await client(parsed.arg, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
const sid = ctx.sessionId()
|
||||
try {
|
||||
const result = await ctx.request('slash.exec', { command: input.slice(1), session_id: sid })
|
||||
const output = readStr(result, 'output') || `/${parsed.name}: no output`
|
||||
const warning = readStr(result, 'warning')
|
||||
const text = warning ? `warning: ${warning}\n${output}` : output
|
||||
// Long output → pager (Ink: >180 chars or >2 non-empty lines), else a system line.
|
||||
present(ctx, titleCase(parsed.name), text)
|
||||
} catch {
|
||||
try {
|
||||
const raw = await ctx.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
||||
handleDispatchResult(parsed, raw, ctx)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user