mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 12:18:44 +08:00
Compare commits
81 Commits
hermes/her
...
feat/opent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a38152cd91 | ||
|
|
7af4055ddc | ||
|
|
de9f3effbb | ||
|
|
ac7ab6c0c0 | ||
|
|
8580172d11 | ||
|
|
af98e6deef | ||
|
|
52aa2f98f9 | ||
|
|
fb1fb1e5ca | ||
|
|
87b33cb10c | ||
|
|
51031ec655 | ||
|
|
edc6e67add | ||
|
|
2d3cf85d67 | ||
|
|
8f112b0633 | ||
|
|
216790a8f8 | ||
|
|
2a25c1c40b | ||
|
|
d0b14bc6ef | ||
|
|
c70620e4a0 | ||
|
|
2b1564199c | ||
|
|
fdc0e5fea5 | ||
|
|
b3d2de87f9 | ||
|
|
cff7b365d2 | ||
|
|
0240299fb0 | ||
|
|
2f30c09378 | ||
|
|
90840708f1 | ||
|
|
60f47eab37 | ||
|
|
04704c103e | ||
|
|
6d2211d9d0 | ||
|
|
cdeef30c62 | ||
|
|
3d3fc24d9a | ||
|
|
2e31140728 | ||
|
|
f4a83c9298 | ||
|
|
00cb21de3e | ||
|
|
0437dd060c | ||
|
|
bc9447d23b | ||
|
|
79dc862680 | ||
|
|
01fa8dcc00 | ||
|
|
3882cc6e61 | ||
|
|
6b87243ecd | ||
|
|
49d90e68c6 | ||
|
|
2cd122c9c1 | ||
|
|
53438228ee | ||
|
|
503c1201ff | ||
|
|
e44b43ad16 | ||
|
|
cd09aa61ef | ||
|
|
c507ca6b3b | ||
|
|
d90e195670 | ||
|
|
793462a395 | ||
|
|
636bb6e928 | ||
|
|
0da48b0c7f | ||
|
|
50023fd151 | ||
|
|
5352aec064 | ||
|
|
7b14c51e7b | ||
|
|
8c1b62e72f | ||
|
|
e136314039 | ||
|
|
73d5c2871d | ||
|
|
d46a8f4492 | ||
|
|
eaee382b47 | ||
|
|
59e9e6a26e | ||
|
|
a046cee754 | ||
|
|
15ccaf9ab9 | ||
|
|
c391add579 | ||
|
|
1e55b3b294 | ||
|
|
76cf809066 | ||
|
|
915b9b5f6f | ||
|
|
1bf9dff1fb | ||
|
|
e14dfa86c6 | ||
|
|
055bc3e3a2 | ||
|
|
c019a9d2d5 | ||
|
|
99b24f6747 | ||
|
|
d4d7c9b0ae | ||
|
|
ba10594322 | ||
|
|
0d0e9203cf | ||
|
|
abdc21f39a | ||
|
|
87634e19fd | ||
|
|
d01b573796 | ||
|
|
a572a1eae4 | ||
|
|
b72ac77783 | ||
|
|
53b37463c4 | ||
|
|
cc2c881fd1 | ||
|
|
12342a4bce | ||
|
|
ea0de82422 |
31
Dockerfile
31
Dockerfile
@@ -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
|
||||
@@ -174,8 +176,15 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
||||
COPY --chown=hermes:hermes . .
|
||||
|
||||
# Build browser dashboard and terminal UI assets.
|
||||
# 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 now ships. (CI must verify the full image build on Node 26.)
|
||||
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
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
|
||||
@@ -105,6 +105,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 |
|
||||
|
||||
@@ -1527,8 +1527,229 @@ 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 _node26_bin_or_none() -> str | None:
|
||||
"""Resolve a Node >= 26.3.0 binary (no exit — a probe), or ``None``.
|
||||
|
||||
``HERMES_NODE`` override > ``node`` on PATH, each gated on version >= 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".
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
|
||||
return [node, "--experimental-ffi", "--no-warnings", 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:
|
||||
@@ -1887,6 +2108,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()}")
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -1924,6 +1924,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)"
|
||||
@@ -2458,6 +2528,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
|
||||
@@ -2565,6 +2641,7 @@ main() {
|
||||
setup_venv
|
||||
install_deps
|
||||
install_node_deps
|
||||
install_opentui
|
||||
setup_path
|
||||
copy_config_templates
|
||||
run_setup_wizard
|
||||
|
||||
@@ -2931,7 +2931,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 = {}
|
||||
|
||||
@@ -2959,9 +2965,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
|
||||
if not content_text.strip():
|
||||
continue
|
||||
@@ -3338,7 +3348,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
|
||||
@@ -8506,6 +8518,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:
|
||||
|
||||
9
ui-opentui/.gitignore
vendored
Normal file
9
ui-opentui/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
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/
|
||||
11
ui-opentui/.prettierrc
Normal file
11
ui-opentui/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false
|
||||
}
|
||||
101
ui-opentui/eslint.config.mjs
Normal file
101
ui-opentui/eslint.config.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import js from "@eslint/js"
|
||||
import tseslint from "typescript-eslint"
|
||||
import unusedImports from "eslint-plugin-unused-imports"
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", ".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" },
|
||||
},
|
||||
},
|
||||
)
|
||||
4916
ui-opentui/package-lock.json
generated
Normal file
4916
ui-opentui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
ui-opentui/package.json
Normal file
41
ui-opentui/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@hermes/ui-opentui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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.0",
|
||||
"@opentui/keymap": "0.4.0",
|
||||
"@opentui/solid": "0.4.0",
|
||||
"effect": "4.0.0-beta.78",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
75
ui-opentui/scripts/acceptance.sh
Normal file
75
ui-opentui/scripts/acceptance.sh
Normal file
@@ -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; }
|
||||
75
ui-opentui/scripts/build.mjs
Normal file
75
ui-opentui/scripts/build.mjs
Normal file
@@ -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"' }
|
||||
})
|
||||
26
ui-opentui/scripts/check.sh
Executable file
26
ui-opentui/scripts/check.sh
Executable file
@@ -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 =="
|
||||
48
ui-opentui/scripts/demo.tsx
Normal file
48
ui-opentui/scripts/demo.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 { 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'
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
void render(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
renderer
|
||||
)
|
||||
288
ui-opentui/scripts/fixture.ts
Normal file
288
ui-opentui/scripts/fixture.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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.
|
||||
const bodyLines = 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[] {
|
||||
const prev = process.env.HERMES_TUI_MAX_MESSAGES
|
||||
process.env.HERMES_TUI_MAX_MESSAGES = String(Number.MAX_SAFE_INTEGER)
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
let pushed = 0
|
||||
let turn = 0
|
||||
while (pushed < total) {
|
||||
applyTurn(store, turn)
|
||||
pushed += rowsPerTurn(turn)
|
||||
turn++
|
||||
}
|
||||
// Restore the env so the bench's own cap (read per-store) is unaffected.
|
||||
if (prev === undefined) delete process.env.HERMES_TUI_MAX_MESSAGES
|
||||
else process.env.HERMES_TUI_MAX_MESSAGES = prev
|
||||
// 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
|
||||
}
|
||||
177
ui-opentui/scripts/mem-bench.tsx
Normal file
177
ui-opentui/scripts/mem-bench.tsx
Normal file
@@ -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()
|
||||
126
ui-opentui/src/boundary/clipboard.ts
Normal file
126
ui-opentui/src/boundary/clipboard.ts
Normal file
@@ -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
|
||||
}
|
||||
29
ui-opentui/src/boundary/errors.ts
Normal file
29
ui-opentui/src/boundary/errors.ts
Normal file
@@ -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
|
||||
}> {}
|
||||
29
ui-opentui/src/boundary/gateway/GatewayService.ts
Normal file
29
ui-opentui/src/boundary/gateway/GatewayService.ts
Normal file
@@ -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'
|
||||
) {}
|
||||
255
ui-opentui/src/boundary/gateway/client.ts
Normal file
255
ui-opentui/src/boundary/gateway/client.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
175
ui-opentui/src/boundary/gateway/liveGateway.ts
Normal file
175
ui-opentui/src/boundary/gateway/liveGateway.ts
Normal file
@@ -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)
|
||||
)
|
||||
)
|
||||
49
ui-opentui/src/boundary/gateway/python.ts
Normal file
49
ui-opentui/src/boundary/gateway/python.ts
Normal file
@@ -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, '../../../../')
|
||||
}
|
||||
248
ui-opentui/src/boundary/log.ts
Normal file
248
ui-opentui/src/boundary/log.ts
Normal file
@@ -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
|
||||
}
|
||||
138
ui-opentui/src/boundary/renderer.ts
Normal file
138
ui-opentui/src/boundary/renderer.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 { getLog } from './log.ts'
|
||||
|
||||
/**
|
||||
* 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: () =>
|
||||
createCliRenderer({
|
||||
// scrollbox clips growing output → no terminal-scrollback corruption (gotcha §8 #2).
|
||||
externalOutputMode: 'passthrough',
|
||||
targetFps: 60,
|
||||
// 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
|
||||
}),
|
||||
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.
|
||||
}
|
||||
}
|
||||
17
ui-opentui/src/boundary/runtime.ts
Normal file
17
ui-opentui/src/boundary/runtime.ts
Normal file
@@ -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)
|
||||
254
ui-opentui/src/boundary/schema/GatewayEvent.ts
Normal file
254
ui-opentui/src/boundary/schema/GatewayEvent.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
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
|
||||
99
ui-opentui/src/boundary/schema/SessionInfo.ts
Normal file
99
ui-opentui/src/boundary/schema/SessionInfo.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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:~1830): top-level `model`,
|
||||
* `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, plus a nested
|
||||
* `usage` (`_get_usage()`, server.py:~1698) carrying `context_used`,
|
||||
* `context_max`, `context_percent`, `compressions` (context_* only present
|
||||
* when the compressor knows a context length).
|
||||
* - 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)
|
||||
})
|
||||
|
||||
export const SessionInfoPatchSchema = Schema.Struct({
|
||||
model: opt(Str),
|
||||
reasoning_effort: opt(Str),
|
||||
fast: opt(Bool),
|
||||
cwd: opt(Str),
|
||||
branch: opt(Str),
|
||||
running: opt(Bool),
|
||||
// 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)
|
||||
64
ui-opentui/src/entry/fakeGateway.ts
Normal file
64
ui-opentui/src/entry/fakeGateway.ts
Normal file
@@ -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 }
|
||||
}
|
||||
492
ui-opentui/src/entry/main.tsx
Normal file
492
ui-opentui/src/entry/main.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* 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 { acquireRenderer } from '../boundary/renderer.ts'
|
||||
import { makeAppLayer } from '../boundary/runtime.ts'
|
||||
import { nthAssistantResponse } from '../logic/copy.ts'
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
|
||||
import { createPasteStore } from '../logic/pastes.ts'
|
||||
import { mapResumeHistory, mapSessionList } from '../logic/resume.ts'
|
||||
import { dispatchSlash, mapCompletions, planCompletion, readReplaceFrom, type SlashContext } from '../logic/slash.ts'
|
||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.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
|
||||
/** Resume a session instead of creating one: a session id, or 'recent'/'last' (→ session.most_recent). */
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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) 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
|
||||
}
|
||||
|
||||
let sid: string | undefined
|
||||
if (input.resumeId) {
|
||||
sid = 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)
|
||||
} else {
|
||||
const created = yield* gateway.request<{ session_id?: string; info?: Record<string, unknown> }>(
|
||||
'session.create',
|
||||
{ cols: input.cols }
|
||||
)
|
||||
sid = created?.session_id ?? gateway.sessionId()
|
||||
if (!sid) {
|
||||
log.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)
|
||||
log.info('bootstrap', 'session created', { sid })
|
||||
}
|
||||
|
||||
// Tools/skills/MCP catalog for the home-screen panel (item 9) — best-effort,
|
||||
// never blocks startup if the RPC is missing/old.
|
||||
const catalog = yield* gateway
|
||||
.request<unknown>('startup.catalog', { session_id: sid })
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
if (catalog) store.setCatalog(catalog)
|
||||
|
||||
const prompt = input.initialPrompt?.trim()
|
||||
if (prompt) {
|
||||
store.pushUser(prompt)
|
||||
yield* gateway.request('prompt.submit', { session_id: sid, text: prompt })
|
||||
}
|
||||
}).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
|
||||
})
|
||||
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) })))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
confirm: (message, 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
|
||||
},
|
||||
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
|
||||
logTail: () =>
|
||||
getLog()
|
||||
.tail(200)
|
||||
.map(e => `${e.scope}: ${e.msg}`),
|
||||
openDashboard: () => store.openDashboard(),
|
||||
openPager: (title, text) => store.openPager(title, text),
|
||||
openPicker: picker => store.openPicker(picker),
|
||||
openSwitcher: sessions => store.openSwitcher(sessions),
|
||||
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
|
||||
}
|
||||
|
||||
// Resume a chosen session (session switcher pick) — same hydrate path as launch.
|
||||
const onResume = (resumeSid: string) => {
|
||||
Effect.runFork(
|
||||
resumeInto(gateway, store, resumeSid, input.cols).pipe(
|
||||
Effect.catchCause(cause => Effect.sync(() => getLog().warn('resume', 'failed', { cause: String(cause) })))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// The composer's submit: route `/command` through the slash ladder, else a prompt.
|
||||
const submit = (text: string) => {
|
||||
if (text.startsWith('/')) void dispatchSlash(text, slashCtx)
|
||||
else submitPrompt(text)
|
||||
}
|
||||
|
||||
// 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).
|
||||
const onType = (text: string) => {
|
||||
const plan = planCompletion(text)
|
||||
if (!plan) {
|
||||
store.clearCompletions()
|
||||
return
|
||||
}
|
||||
Effect.runPromise(gateway.request(plan.method, plan.params))
|
||||
.then(result => store.setCompletions(mapCompletions(result), readReplaceFrom(result, plan.from)))
|
||||
.catch(() => 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))
|
||||
|
||||
// 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}>
|
||||
<App
|
||||
store={store}
|
||||
onSubmit={submit}
|
||||
onType={onType}
|
||||
onRespond={respond}
|
||||
onResume={onResume}
|
||||
sessionId={() => gateway.sessionId()}
|
||||
history={history}
|
||||
onImagePaste={onImagePaste}
|
||||
pasteStore={pasteStore}
|
||||
/>
|
||||
</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
|
||||
const initialPrompt = process.env.HERMES_TUI_PROMPT?.trim() || process.argv.slice(2).join(' ').trim()
|
||||
const resumeId = process.env.HERMES_TUI_RESUME?.trim()
|
||||
// Mouse on by default (opencode parity: wheel-scroll the transcript, drag the
|
||||
// scrollbar, click-to-expand tools, text-aware selection). HERMES_TUI_MOUSE=0 opts out.
|
||||
const mouse = envFlag(process.env.HERMES_TUI_MOUSE, true)
|
||||
const base = { mouse, fake, cols }
|
||||
const withPrompt = initialPrompt ? { ...base, initialPrompt } : base
|
||||
const input: TuiInput = resumeId ? { ...withPrompt, resumeId } : withPrompt
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
41
ui-opentui/src/logic/copy.ts
Normal file
41
ui-opentui/src/logic/copy.ts
Normal file
@@ -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]
|
||||
}
|
||||
12
ui-opentui/src/logic/defer.ts
Normal file
12
ui-opentui/src/logic/defer.ts
Normal file
@@ -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)
|
||||
}
|
||||
16
ui-opentui/src/logic/env.ts
Normal file
16
ui-opentui/src/logic/env.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
52
ui-opentui/src/logic/gatewayRecovery.ts
Normal file
52
ui-opentui/src/logic/gatewayRecovery.ts
Normal file
@@ -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)
|
||||
}
|
||||
122
ui-opentui/src/logic/history.ts
Normal file
122
ui-opentui/src/logic/history.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
ui-opentui/src/logic/pastes.ts
Normal file
50
ui-opentui/src/logic/pastes.ts
Normal file
@@ -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
|
||||
}
|
||||
101
ui-opentui/src/logic/resume.ts
Normal file
101
ui-opentui/src/logic/resume.ts
Normal file
@@ -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
|
||||
}
|
||||
349
ui-opentui/src/logic/slash.ts
Normal file
349
ui-opentui/src/logic/slash.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 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 type { CompletionItem, PickerItem, PickerState, SessionItem } 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) }
|
||||
}
|
||||
|
||||
/** 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[]
|
||||
/** Fetch the resumable sessions (`session.list`) for the switcher. */
|
||||
readonly listSessions: () => Promise<SessionItem[]>
|
||||
/** Open the session switcher with the given rows. */
|
||||
readonly openSwitcher: (sessions: SessionItem[]) => void
|
||||
/** Open a generic picker (model picker, skills hub). */
|
||||
readonly openPicker: (picker: PickerState) => void
|
||||
/** Open the agents dashboard (/agents, /tasks). */
|
||||
readonly openDashboard: () => void
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** A path-like last token worth file/@-mention completion (mirrors Ink's TAB_PATH_RE intent). */
|
||||
function isPathLike(word: string): boolean {
|
||||
return (
|
||||
word.startsWith('@') ||
|
||||
word.startsWith('~') ||
|
||||
word.startsWith('./') ||
|
||||
word.startsWith('../') ||
|
||||
word.startsWith('/') ||
|
||||
word.includes('/')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to complete for the current composer text (cursor assumed at end):
|
||||
* - `/command [args]` → `complete.slash {text}` (the gateway completes names AND
|
||||
* args, e.g. /details section names),
|
||||
* - a trailing path-like word (`@…`, `~/…`, `./…`, `/…`, or anything with `/`) →
|
||||
* `complete.path {word}` for file/dir tagging,
|
||||
* - otherwise nothing.
|
||||
* Returns null when there's no completion to run (so the dropdown clears).
|
||||
*/
|
||||
export function planCompletion(text: string): CompletionPlan | null {
|
||||
if (text.includes('\n')) return null
|
||||
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } }
|
||||
const word = /(\S+)$/.exec(text)?.[1]
|
||||
if (word && isPathLike(word)) {
|
||||
return { from: text.length - word.length, 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
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
const CLIENT_HELP = [
|
||||
'/help — list commands',
|
||||
'/model [name] — switch model (picker if bare)',
|
||||
'/copy [n] — copy the last (or n-th) response',
|
||||
'/skills — browse skills',
|
||||
'/sessions, /resume — switch/resume a session',
|
||||
'/clear, /new — clear the transcript (confirm)',
|
||||
'/logs — recent engine log lines',
|
||||
'/quit, /exit — quit',
|
||||
'(other /commands run on the gateway)'
|
||||
].join('\n')
|
||||
|
||||
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
||||
|
||||
/** Fetch sessions and open the switcher (shared by /sessions, /resume, /switch, /session). */
|
||||
const openSwitcher: ClientHandler = async (_arg, ctx) => {
|
||||
const sessions = await ctx.listSessions()
|
||||
if (sessions.length) ctx.openSwitcher(sessions)
|
||||
else ctx.pushSystem('No sessions to resume.')
|
||||
}
|
||||
|
||||
/** Flatten `model.options` (authenticated providers' models) into picker rows; mark the current. */
|
||||
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 items: PickerItem[] = []
|
||||
for (const p of providers) {
|
||||
if (!p || typeof p !== 'object' || (p as { authenticated?: unknown }).authenticated !== true) continue
|
||||
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
|
||||
const models = (p as { models?: unknown }).models
|
||||
if (!Array.isArray(models)) continue
|
||||
for (const m of models) {
|
||||
if (typeof m === 'string') items.push({ description: slug, label: m === current ? `${m} ✓` : m, value: m })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into picker rows. */
|
||||
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({ description: category, label: n, value: n })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** Switch the model via the server (shared by `/model <name>` and the picker pick). */
|
||||
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}`)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/model` — bare opens the model picker; `/model <name>` switches directly. */
|
||||
const modelCmd: ClientHandler = async (arg, ctx) => {
|
||||
if (arg.trim()) {
|
||||
await switchModel(ctx, arg.trim())
|
||||
return
|
||||
}
|
||||
const items = mapModelOptions(await ctx.request('model.options', {}))
|
||||
if (!items.length) {
|
||||
ctx.pushSystem('No models available (no authenticated providers).')
|
||||
return
|
||||
}
|
||||
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
|
||||
}
|
||||
|
||||
/** `/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
|
||||
}
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
/** `/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'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The TUI-only client commands (run in-process, never hit the gateway). */
|
||||
const CLIENT: Record<string, ClientHandler> = {
|
||||
agents: (_arg, ctx) => ctx.openDashboard(),
|
||||
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
||||
copy: (arg, ctx) => {
|
||||
const n = Math.max(1, Number.parseInt(arg, 10) || 1)
|
||||
if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.')
|
||||
},
|
||||
exit: (_arg, ctx) => ctx.quit(),
|
||||
model: modelCmd,
|
||||
resume: openSwitcher,
|
||||
session: openSwitcher,
|
||||
sessions: openSwitcher,
|
||||
skills: skillsCmd,
|
||||
switch: openSwitcher,
|
||||
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) || CLIENT_HELP)
|
||||
} catch {
|
||||
ctx.pushSystem(CLIENT_HELP)
|
||||
}
|
||||
},
|
||||
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()
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
863
ui-opentui/src/logic/store.ts
Normal file
863
ui-opentui/src/logic/store.ts
Normal file
@@ -0,0 +1,863 @@
|
||||
/**
|
||||
* Session/message store — the SOLID side (spec v4 §1, §7). Plain `createStore`
|
||||
* + an `apply(event)` reducer, à la opencode `context/sync-v2.tsx`. NOT Effect.
|
||||
* The boundary calls `apply` with already-decoded `GatewayEvent`s via
|
||||
* GatewayService.subscribe.
|
||||
*
|
||||
* Phase 2b: an assistant turn is ONE ordered `parts[]` of a discriminated union
|
||||
* (text / reasoning / tool), so tool calls render INLINE between text blocks
|
||||
* instead of dumped as separate rows below (§7 — the "dump-below" bug). Tools are
|
||||
* matched start↔complete by `tool_id`; `tool.complete` updates that part IN PLACE.
|
||||
* User/system rows stay flat `text` (no parts). Carried from Phase 1: streaming
|
||||
* concat (prefer `payload.text`), skin→theme, LRU dedup, hydrate-while-buffering.
|
||||
*/
|
||||
import { Option } from 'effect'
|
||||
import { createStore, produce } from 'solid-js/store'
|
||||
|
||||
import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts'
|
||||
import {
|
||||
decodeCatalog,
|
||||
decodeSessionInfoPatch,
|
||||
type CatalogDecoded,
|
||||
type SessionInfoPatchDecoded
|
||||
} from '../boundary/schema/SessionInfo.ts'
|
||||
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
||||
|
||||
/** A tool call inside an assistant turn (matched start↔complete by `id`=tool_id). */
|
||||
export interface ToolPartState {
|
||||
type: 'tool'
|
||||
id: string
|
||||
name: string
|
||||
state: 'running' | 'complete'
|
||||
/** Envelope-stripped output (multi-line → block render; the view caps it). */
|
||||
resultText?: string
|
||||
/** Short one-line status when there's no substantial output. */
|
||||
summary?: string
|
||||
error?: string
|
||||
lineCount?: number
|
||||
/** One-line primary-arg preview from gateway `context` (always sent; redaction-safe). */
|
||||
argsPreview?: string
|
||||
/** Full args (pretty JSON) for the expanded view — `args_text` (redacted) or stringified `args`. */
|
||||
argsText?: string
|
||||
/** Tool wall-clock seconds (gateway `duration_s`), shown dim in the header. */
|
||||
duration?: number
|
||||
/** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */
|
||||
omittedNote?: string
|
||||
}
|
||||
|
||||
/** One ordered piece of an assistant turn (§7). */
|
||||
export type Part =
|
||||
| { type: 'text'; id: string; text: string }
|
||||
| { type: 'reasoning'; id: string; text: string }
|
||||
| ToolPartState
|
||||
|
||||
export interface Message {
|
||||
readonly role: 'user' | 'assistant' | 'system'
|
||||
/** Flat body for user/system rows (and settled/resumed assistant rows). */
|
||||
text: string
|
||||
/** Ordered parts for a live assistant turn; absent for user/system. */
|
||||
parts?: Part[]
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A BLOCKING interactive request from the agent (spec §8 #6 — unhandled = deadlock).
|
||||
* Each is answered via the matching `*.respond` RPC; Esc/Ctrl+C sends deny/empty.
|
||||
*/
|
||||
export type ActivePrompt =
|
||||
| { kind: 'clarify'; question: string; choices: string[] | null; requestId: string }
|
||||
| { kind: 'approval'; command: string; description: string }
|
||||
| { kind: 'sudo'; requestId: string }
|
||||
| { kind: 'secret'; envVar: string; prompt: string; requestId: string }
|
||||
// local (non-gateway) Y/N confirm — e.g. /clear, /new (spec §2a)
|
||||
| { kind: 'confirm'; message: string; onConfirm: () => void }
|
||||
|
||||
/** A full-screen scrollable text viewer (long slash output: /status, /logs, …). */
|
||||
export interface PagerState {
|
||||
title: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/** One row in the session switcher (from `session.list`). */
|
||||
export interface SessionItem {
|
||||
id: string
|
||||
title: string
|
||||
preview: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
/** A row in a generic `<select>` picker (model picker, skills hub, …). */
|
||||
export interface PickerItem {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** An open generic picker overlay: a titled list whose pick runs `onPick(value)`. */
|
||||
export interface PickerState {
|
||||
title: string
|
||||
items: PickerItem[]
|
||||
onPick: (value: string) => void
|
||||
}
|
||||
|
||||
/** A slash-completion candidate (from `complete.slash`). */
|
||||
export interface CompletionItem {
|
||||
text: string
|
||||
display: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
/** A delegated subagent, tracked from the `subagent.*` event stream (agents dashboard). */
|
||||
export interface SubagentInfo {
|
||||
id: string
|
||||
goal: string
|
||||
status: string
|
||||
depth: number
|
||||
model?: string
|
||||
parentId?: string
|
||||
summary?: string
|
||||
lastTool?: string
|
||||
/** Live activity trace (item 15) — tool/progress/summary lines, newest last. */
|
||||
trace?: string[]
|
||||
/** Latest thinking text (transient; not appended to the trace to avoid flooding). */
|
||||
thought?: string
|
||||
}
|
||||
|
||||
/** Cap on a subagent's retained trace lines. */
|
||||
const SUBAGENT_TRACE_LIMIT = 200
|
||||
|
||||
/**
|
||||
* Live session chrome (the status bar — item 14). Sourced from the `session.info`
|
||||
* event (and the `session.create`/`resume` result's `info`), refreshed whenever
|
||||
* the gateway's agent/config state changes. `running` is the turn-active flag the
|
||||
* Ctrl-C interrupt (item 11) reads; we also flip it locally on message.start/
|
||||
* complete so the bar reacts instantly even if a `session.info` lags.
|
||||
*/
|
||||
export interface SessionInfo {
|
||||
model?: string
|
||||
effort?: string
|
||||
fast?: boolean
|
||||
cwd?: string
|
||||
branch?: string
|
||||
running?: boolean
|
||||
contextUsed?: number
|
||||
contextMax?: number
|
||||
contextPercent?: number
|
||||
compressions?: number
|
||||
}
|
||||
|
||||
/** Startup catalog (tools/skills/MCP) for the home-screen panel (item 9 / banner parity). */
|
||||
export interface Catalog {
|
||||
readonly tools: {
|
||||
readonly total: number
|
||||
readonly toolsets: ReadonlyArray<{ name: string; count: number; enabled: boolean; tools: ReadonlyArray<string> }>
|
||||
}
|
||||
readonly skills: { readonly total: number; readonly categories: ReadonlyArray<{ name: string; count: number }> }
|
||||
readonly mcp: { readonly servers: ReadonlyArray<string> }
|
||||
}
|
||||
|
||||
export interface StoreState {
|
||||
ready: boolean
|
||||
messages: Message[]
|
||||
/** Count of oldest messages trimmed from the DISPLAY by the rolling cap (live
|
||||
* overflow + resume slice). Drives the "N earlier messages" truncation notice;
|
||||
* 0 when nothing's been dropped. NOT context loss — the model's history lives on
|
||||
* the gateway (see MESSAGE_CAP); this only bounds in-TUI scrollback. */
|
||||
dropped: number
|
||||
theme: Theme
|
||||
/** The active blocking prompt (composer is hidden while set); undefined when none. */
|
||||
prompt: ActivePrompt | undefined
|
||||
/** The open pager overlay (replaces the transcript while set); undefined when none. */
|
||||
pager: PagerState | undefined
|
||||
/** The open session switcher (replaces the composer while set); undefined when none. */
|
||||
switcher: SessionItem[] | undefined
|
||||
/** The open generic picker (model/skills/…); undefined when none. */
|
||||
picker: PickerState | undefined
|
||||
/** Live completion candidates (slash-name/args or file/@-mention) shown above the composer. */
|
||||
completions: CompletionItem[] | undefined
|
||||
/** Char offset in the input where an accepted completion should start replacing
|
||||
* (gateway `replace_from` for slash args; the path-token start for @-mentions). */
|
||||
completionFrom: number
|
||||
/** Delegated subagents (from `subagent.*`), shown in the agents dashboard. */
|
||||
subagents: SubagentInfo[]
|
||||
/** Whether the agents dashboard overlay is open (/agents). */
|
||||
dashboard: boolean
|
||||
/** Transient busy indicator (the kaomoji face/verb from `thinking.delta`/`status.update`);
|
||||
* shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */
|
||||
status: string | undefined
|
||||
/** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */
|
||||
info: SessionInfo
|
||||
/** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" — item 11);
|
||||
* takes visual priority over the busy `status` face. Undefined when none. */
|
||||
hint: string | undefined
|
||||
/** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */
|
||||
catalog: Catalog | undefined
|
||||
/** The current session id (shown in the home panel; updated on create/resume). */
|
||||
sessionId: string | undefined
|
||||
}
|
||||
|
||||
const LRU_LIMIT = 1000
|
||||
|
||||
/** Read a string field off an unknown payload record (no `any`, no cast). */
|
||||
function readStr(payload: { readonly [k: string]: unknown }, key: string): string | undefined {
|
||||
const v = payload[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
/** Read a number field off an unknown payload record. */
|
||||
function readNum(payload: { readonly [k: string]: unknown }, key: string): number {
|
||||
const v = payload[key]
|
||||
return typeof v === 'number' ? v : 0
|
||||
}
|
||||
|
||||
/** Read an optional number (undefined when absent) — distinguishes "0" from "missing". */
|
||||
function readOptNum(payload: { readonly [k: string]: unknown }, key: string): number | undefined {
|
||||
const v = payload[key]
|
||||
return typeof v === 'number' ? v : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a `session.info` / `session.create.info` payload into a partial SessionInfo.
|
||||
* The loose wire JSON is decoded ONCE via `SessionInfoPatchSchema` (decode-at-
|
||||
* boundary); context/usage numbers are read from the nested `usage` object first,
|
||||
* falling back to the top level (the gateway shapes vary by RPC vs event). A
|
||||
* malformed payload decodes to `Option.none` → an empty patch (never crashes).
|
||||
* Only present fields are included so a partial patch can't clobber prior chrome.
|
||||
*/
|
||||
function readInfoPatch(payload: { readonly [k: string]: unknown }): Partial<SessionInfo> {
|
||||
const decoded = decodeSessionInfoPatch(payload)
|
||||
if (Option.isNone(decoded)) return {}
|
||||
return infoPatchFrom(decoded.value)
|
||||
}
|
||||
|
||||
/** Build the SessionInfo patch from a decoded session.info payload. */
|
||||
function infoPatchFrom(d: SessionInfoPatchDecoded): Partial<SessionInfo> {
|
||||
const patch: Partial<SessionInfo> = {}
|
||||
if (d.model) patch.model = d.model
|
||||
if (d.reasoning_effort) patch.effort = d.reasoning_effort
|
||||
if (d.fast !== undefined) patch.fast = d.fast
|
||||
if (d.cwd) patch.cwd = d.cwd
|
||||
if (d.branch) patch.branch = d.branch
|
||||
if (d.running !== undefined) patch.running = d.running
|
||||
// prefer the nested usage.context_* numbers, else the top-level fallback.
|
||||
const used = d.usage?.context_used ?? d.context_used
|
||||
if (used !== undefined) patch.contextUsed = used
|
||||
const max = d.usage?.context_max ?? d.context_max
|
||||
if (max !== undefined) patch.contextMax = max
|
||||
const pct = d.usage?.context_percent ?? d.context_percent
|
||||
if (pct !== undefined) patch.contextPercent = pct
|
||||
const comp = d.usage?.compressions ?? d.compressions
|
||||
if (comp !== undefined) patch.compressions = comp
|
||||
return patch
|
||||
}
|
||||
|
||||
/** Keep only the string elements of a decoded (unknown-element) array. */
|
||||
function onlyStrings(items: ReadonlyArray<unknown> | undefined): string[] {
|
||||
return (items ?? []).filter((s): s is string => typeof s === 'string')
|
||||
}
|
||||
|
||||
/** Build the typed Catalog from a decoded startup.catalog result (item 9). An
|
||||
* absent `enabled` flag means on; nameless toolsets/categories are dropped and
|
||||
* non-string tool/server names are filtered (defensive — wire arrays are loose). */
|
||||
function catalogFrom(d: CatalogDecoded): Catalog {
|
||||
return {
|
||||
mcp: { servers: onlyStrings(d.mcp?.servers) },
|
||||
skills: {
|
||||
total: d.skills?.total ?? 0,
|
||||
categories: (d.skills?.categories ?? [])
|
||||
.map(c => ({ count: c.count ?? 0, name: c.name ?? '' }))
|
||||
.filter(c => c.name)
|
||||
},
|
||||
tools: {
|
||||
total: d.tools?.total ?? 0,
|
||||
toolsets: (d.tools?.toolsets ?? [])
|
||||
.map(t => ({
|
||||
count: t.count ?? 0,
|
||||
enabled: t.enabled !== false,
|
||||
name: t.name ?? '',
|
||||
tools: onlyStrings(t.tools)
|
||||
}))
|
||||
.filter(t => t.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The subagent status implied by an event type (an explicit payload `status` wins). */
|
||||
function subagentStatusFor(type: string): string {
|
||||
if (type === 'subagent.complete') return 'complete'
|
||||
if (type === 'subagent.thinking') return 'thinking'
|
||||
if (type === 'subagent.tool') return 'tool'
|
||||
if (type === 'subagent.progress') return 'working'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
export function createSessionStore() {
|
||||
// Rolling cap on retained transcript rows. OpenTUI lays out via Yoga (WASM), whose
|
||||
// linear memory is grow-only — every live `<For>` row is a Yoga-node subtree, so an
|
||||
// uncapped `messages[]` ratchets the high-water mark up over a long session and never
|
||||
// gives it back. Capping the array in place (see `capMessages`) makes Solid's keyed
|
||||
// `<For>` UNMOUNT exactly the evicted oldest rows → `Renderable.destroy()` →
|
||||
// `yogaNode.free()`, returning those nodes to the WASM allocator's free list.
|
||||
//
|
||||
// Default 3000 (≈1500 turns of scrollback): the highest cap whose steady-state RSS
|
||||
// stays within a sane TUI budget on the realistic-fixture bench (~20.4 renderables/
|
||||
// msg, ~0.65 MB/msg → ~2 GB at 3000 — and that ceiling is only reached by marathon
|
||||
// 3000+-message sessions; typical sessions cost a fraction). opencode caps at 100;
|
||||
// we trade memory for far more in-TUI scrollback (the dashboard holds the rest).
|
||||
// Read once per store from `HERMES_TUI_MAX_MESSAGES`. Turns trimmed beyond the cap
|
||||
// aren't lost — they live on the gateway and are recoverable via `/resume`.
|
||||
const MESSAGE_CAP = (() => {
|
||||
const raw = Number.parseInt(process.env.HERMES_TUI_MAX_MESSAGES ?? '', 10)
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 3000
|
||||
})()
|
||||
|
||||
const [state, setState] = createStore<StoreState>({
|
||||
ready: false,
|
||||
messages: [],
|
||||
dropped: 0,
|
||||
theme: DEFAULT_THEME,
|
||||
prompt: undefined,
|
||||
pager: undefined,
|
||||
switcher: undefined,
|
||||
picker: undefined,
|
||||
completions: undefined,
|
||||
completionFrom: 0,
|
||||
subagents: [],
|
||||
dashboard: false,
|
||||
status: undefined,
|
||||
info: {},
|
||||
hint: undefined,
|
||||
catalog: undefined,
|
||||
sessionId: undefined
|
||||
})
|
||||
|
||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
||||
// text part doesn't remount/re-tokenize it).
|
||||
let partSeq = 0
|
||||
const nextId = () => `p${++partSeq}`
|
||||
|
||||
// LRU id-dedup: events that carry a stable id are applied at most once.
|
||||
const applied = new Set<string>()
|
||||
function duplicate(id: string | undefined): boolean {
|
||||
if (!id) return false
|
||||
if (applied.has(id)) return true
|
||||
applied.add(id)
|
||||
if (applied.size > LRU_LIMIT) {
|
||||
const oldest = applied.values().next()
|
||||
if (!oldest.done) applied.delete(oldest.value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Hydrate-while-buffering (resume): while a snapshot is loading, live events
|
||||
// queue here and replay after the snapshot is reconciled (opencode sync-v2).
|
||||
let buffering: GatewayEvent[] | null = null
|
||||
|
||||
// Anti-flood for `gateway.stderr`: a crashing child can emit a torrent of
|
||||
// stderr lines, so we do NOT push each to the transcript. Instead we keep a
|
||||
// small ring of the most-recent lines and only surface a TAIL of it when a
|
||||
// failure event (start_timeout / exited) actually needs the diagnostic
|
||||
// context — so a healthy-but-chatty gateway never spams the chat.
|
||||
const STDERR_RING_LIMIT = 20
|
||||
const STDERR_TAIL = 5
|
||||
const stderrRing: string[] = []
|
||||
function stderrTail(): string {
|
||||
return stderrRing.slice(-STDERR_TAIL).join('\n')
|
||||
}
|
||||
|
||||
function setSkin(skin: GatewaySkinDecoded | undefined): void {
|
||||
setState('theme', themeFromSkin(skin))
|
||||
}
|
||||
|
||||
// Trim the transcript to MESSAGE_CAP, dropping the OLDEST rows IN PLACE via
|
||||
// `splice` (NOT a `slice`-reassign). A keyed `<For>` keeps rows by item
|
||||
// REFERENCE, so splicing the head unmounts only the evicted rows (freeing their
|
||||
// Yoga nodes) while the survivors keep their refs and are not remounted. A live
|
||||
// streaming assistant turn is always the LAST row, so head-trimming never drops it.
|
||||
function capMessages(draft: StoreState): void {
|
||||
const overflow = draft.messages.length - MESSAGE_CAP
|
||||
if (overflow > 0) {
|
||||
draft.messages.splice(0, overflow)
|
||||
draft.dropped += overflow
|
||||
}
|
||||
}
|
||||
|
||||
// ── parts helpers (operate on a draft message inside produce) ───────────
|
||||
function appendPart(m: Message, type: 'text' | 'reasoning', text: string): void {
|
||||
const parts = (m.parts ??= [])
|
||||
const last = parts[parts.length - 1]
|
||||
if (last && last.type === type) last.text += text
|
||||
else parts.push({ type, id: nextId(), text })
|
||||
}
|
||||
|
||||
/** The live (last) assistant message, optionally only when still streaming. */
|
||||
function liveAssistant(draft: StoreState, streamingOnly = false): Message | undefined {
|
||||
const last = draft.messages[draft.messages.length - 1]
|
||||
if (last && last.role === 'assistant' && (!streamingOnly || last.streaming)) return last
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Ensure there's an open assistant turn to attach parts to (tool/reasoning). */
|
||||
function ensureAssistant(draft: StoreState): Message {
|
||||
const live = liveAssistant(draft, true)
|
||||
if (live) return live
|
||||
const created: Message = { role: 'assistant', text: '', parts: [], streaming: true }
|
||||
draft.messages.push(created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** Find a tool part by id in the CURRENT (last) assistant turn — a tool.complete
|
||||
* always pairs with a tool.start in the live turn, so scoping there avoids
|
||||
* matching a same-id tool in an older/resumed turn (and is O(parts), not O(all)). */
|
||||
function findToolPart(draft: StoreState, id: string): ToolPartState | undefined {
|
||||
const parts = liveAssistant(draft)?.parts
|
||||
if (!parts) return undefined
|
||||
for (let j = parts.length - 1; j >= 0; j--) {
|
||||
const p = parts[j]
|
||||
if (p && p.type === 'tool' && p.id === id) return p
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Push a user message (composer submit). */
|
||||
function pushUser(text: string) {
|
||||
setState(
|
||||
produce(draft => {
|
||||
draft.messages.push({ role: 'user', text })
|
||||
capMessages(draft)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Push a system line (slash output, errors, notices). */
|
||||
function pushSystem(text: string) {
|
||||
// slash/notice text is often ANSI-colored for the Ink TUI; strip codes so
|
||||
// they don't render as literal `[1;38m…` glyphs in the native engine (item 8).
|
||||
const clean = stripAnsi(text)
|
||||
setState(
|
||||
produce(draft => {
|
||||
draft.messages.push({ role: 'system', text: clean })
|
||||
capMessages(draft)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Clear the transcript (e.g. /clear, /new) and any tracked subagents. */
|
||||
function clearTranscript() {
|
||||
setState('messages', [])
|
||||
setState('subagents', [])
|
||||
setState('dropped', 0)
|
||||
// Drop the dedup history too — a fresh transcript should re-process any id.
|
||||
applied.clear()
|
||||
}
|
||||
|
||||
/** Open / close the agents dashboard overlay (/agents). */
|
||||
function openDashboard() {
|
||||
setState('dashboard', true)
|
||||
}
|
||||
function closeDashboard() {
|
||||
setState('dashboard', false)
|
||||
}
|
||||
|
||||
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
|
||||
function setConfirm(message: string, onConfirm: () => void) {
|
||||
setState('prompt', { kind: 'confirm', message, onConfirm })
|
||||
}
|
||||
|
||||
/** Open the pager overlay (long slash output: /status, /logs, …). */
|
||||
function openPager(title: string, text: string) {
|
||||
setState('pager', { title, text: stripAnsi(text) })
|
||||
}
|
||||
|
||||
/** Close the pager overlay. */
|
||||
function closePager() {
|
||||
setState('pager', undefined)
|
||||
}
|
||||
|
||||
/** Open the session switcher with the given session rows (/sessions, /resume). */
|
||||
function openSwitcher(sessions: SessionItem[]) {
|
||||
setState('switcher', sessions)
|
||||
}
|
||||
|
||||
/** Close the session switcher. */
|
||||
function closeSwitcher() {
|
||||
setState('switcher', undefined)
|
||||
}
|
||||
|
||||
/** Open the generic picker (model picker, skills hub, …). */
|
||||
function openPicker(picker: PickerState) {
|
||||
setState('picker', picker)
|
||||
}
|
||||
|
||||
/** Close the generic picker. */
|
||||
function closePicker() {
|
||||
setState('picker', undefined)
|
||||
}
|
||||
|
||||
/** Set / clear the transient composer hint ("Ctrl+C again to quit" — item 11). */
|
||||
function setHint(text: string | undefined): void {
|
||||
setState('hint', text)
|
||||
}
|
||||
|
||||
/** Merge a session-info patch into the chrome state (status bar — item 14). */
|
||||
function applyInfo(raw: { readonly [k: string]: unknown }): void {
|
||||
const patch = readInfoPatch(raw)
|
||||
if (Object.keys(patch).length) setState('info', prev => ({ ...prev, ...patch }))
|
||||
}
|
||||
|
||||
/** Set / clear the live completion candidates (composer dropdown). `from` is the
|
||||
* input char offset an accepted item replaces from (slash-arg / @-mention splice). */
|
||||
function setCompletions(items: CompletionItem[], from = 0) {
|
||||
setState('completions', items.length ? items : undefined)
|
||||
setState('completionFrom', items.length ? Math.max(0, from) : 0)
|
||||
}
|
||||
function clearCompletions() {
|
||||
setState('completions', undefined)
|
||||
setState('completionFrom', 0)
|
||||
}
|
||||
|
||||
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
|
||||
function apply(event: GatewayEvent): void {
|
||||
if (buffering) {
|
||||
buffering.push(event)
|
||||
return
|
||||
}
|
||||
applyNow(event)
|
||||
}
|
||||
|
||||
function applyNow(event: GatewayEvent): void {
|
||||
switch (event.type) {
|
||||
case 'gateway.ready':
|
||||
setState('ready', true)
|
||||
// Clear any transient status: on a recovery-respawn ready this drops the
|
||||
// lingering 'gateway recovering (attempt N)…' line; no-op on first connect.
|
||||
setState('status', undefined)
|
||||
setSkin(event.payload?.skin)
|
||||
break
|
||||
case 'skin.changed':
|
||||
setSkin(event.payload)
|
||||
break
|
||||
case 'session.info':
|
||||
applyInfo(event.payload)
|
||||
break
|
||||
case 'message.start':
|
||||
setState('status', undefined)
|
||||
setState('info', prev => ({ ...prev, running: true }))
|
||||
setState(
|
||||
produce(draft => {
|
||||
draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true })
|
||||
capMessages(draft)
|
||||
})
|
||||
)
|
||||
break
|
||||
case 'message.delta': {
|
||||
// prefer `text` over `rendered` (gotcha §8 #4 — rendered is incremental Rich-ANSI).
|
||||
const text = event.payload?.text ?? ''
|
||||
if (!text) break
|
||||
setState(
|
||||
produce(draft => {
|
||||
const live = liveAssistant(draft, true)
|
||||
if (live) appendPart(live, 'text', text)
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'message.complete':
|
||||
setState(
|
||||
produce(draft => {
|
||||
// complete-only gateways may send `message.complete{text}` with no prior
|
||||
// start/delta → create the turn so the final text isn't dropped.
|
||||
const finalText = event.payload?.text
|
||||
const live = liveAssistant(draft, true) ?? (finalText ? ensureAssistant(draft) : undefined)
|
||||
if (!live) return
|
||||
// If no deltas arrived (complete-only gateways), seed the full text once.
|
||||
const hasText = (live.parts ?? []).some(p => p.type === 'text' && p.text.length > 0)
|
||||
if (finalText && !hasText) appendPart(live, 'text', finalText)
|
||||
live.streaming = false
|
||||
})
|
||||
)
|
||||
setState('status', undefined)
|
||||
setState('info', prev => ({ ...prev, running: false }))
|
||||
// message.complete carries the latest usage/context — refresh the bar.
|
||||
if (event.payload) applyInfo(event.payload)
|
||||
break
|
||||
// thinking.delta / status.update are the TRANSIENT busy indicator (kaomoji
|
||||
// face/verb) — route them to the status line, NOT the transcript (gotcha: Ink
|
||||
// shows these as a FaceTicker, not message content).
|
||||
case 'thinking.delta':
|
||||
case 'status.update': {
|
||||
const text = event.payload?.text ?? ''
|
||||
if (text) setState('status', text)
|
||||
break
|
||||
}
|
||||
// reasoning.delta is the model's actual reasoning — a (dim) transcript part.
|
||||
case 'reasoning.delta': {
|
||||
const text = event.payload?.text ?? ''
|
||||
if (!text) break
|
||||
setState(
|
||||
produce(draft => {
|
||||
appendPart(ensureAssistant(draft), 'reasoning', text)
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'tool.start': {
|
||||
const id = readStr(event.payload, 'tool_id')
|
||||
if (!id) break
|
||||
const name = readStr(event.payload, 'name') ?? 'tool'
|
||||
// `context` = build_tool_preview's primary-arg line (always sent); `args_text`
|
||||
// = redacted full-arg JSON (verbose mode only). Surfacing these is item 2.
|
||||
const argsPreview = readStr(event.payload, 'context')
|
||||
const argsText = readStr(event.payload, 'args_text')
|
||||
setState(
|
||||
produce(draft => {
|
||||
const live = ensureAssistant(draft)
|
||||
const part: ToolPartState = { type: 'tool', id, name, state: 'running' }
|
||||
if (argsPreview) part.argsPreview = argsPreview
|
||||
if (argsText) part.argsText = argsText
|
||||
;(live.parts ??= []).push(part)
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'tool.complete': {
|
||||
const id = readStr(event.payload, 'tool_id')
|
||||
if (!id) break
|
||||
const name = readStr(event.payload, 'name')
|
||||
const error = readStr(event.payload, 'error')
|
||||
const summary = readStr(event.payload, 'summary')
|
||||
// Peel the gateway's "[showing verbose tail; omitted …]" label (item 2) before
|
||||
// envelope-stripping, so the body is clean and the note renders tidily.
|
||||
const { body: rawBody, omittedNote } = stripOmittedNote(readStr(event.payload, 'result_text') ?? summary ?? '')
|
||||
const resultText = stripToolEnvelope(rawBody)
|
||||
const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0
|
||||
// `args` (full dict) is always sent; stringify as the expanded-view args
|
||||
// when verbose `args_text` wasn't captured on start. `duration_s` → header.
|
||||
const argsObj = event.payload['args']
|
||||
const duration = readOptNum(event.payload, 'duration_s')
|
||||
setState(
|
||||
produce(draft => {
|
||||
let part = findToolPart(draft, id)
|
||||
if (!part) {
|
||||
// complete without a matching start — append a settled tool part.
|
||||
part = { type: 'tool', id, name: name ?? 'tool', state: 'running' }
|
||||
;(ensureAssistant(draft).parts ??= []).push(part)
|
||||
}
|
||||
part.state = 'complete'
|
||||
part.lineCount = lineCount
|
||||
if (name) part.name = name
|
||||
if (resultText) part.resultText = resultText
|
||||
if (summary) part.summary = summary
|
||||
if (error) part.error = error
|
||||
if (duration !== undefined) part.duration = duration
|
||||
if (omittedNote) part.omittedNote = omittedNote
|
||||
// argsPreview (from tool.start `context`) is intentionally NOT overwritten.
|
||||
if (!part.argsText && argsObj && typeof argsObj === 'object') {
|
||||
try {
|
||||
part.argsText = JSON.stringify(argsObj, null, 2)
|
||||
} catch {
|
||||
/* unstringifiable args — leave unset */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
// ── blocking prompts (spec §8 #6 — unhandled = the agent deadlocks) ──
|
||||
case 'clarify.request':
|
||||
setState('prompt', {
|
||||
kind: 'clarify',
|
||||
question: event.payload.question ?? '',
|
||||
// decoded choices are readonly — copy to the store's mutable string[]
|
||||
choices: event.payload.choices ? [...event.payload.choices] : null,
|
||||
requestId: event.payload.request_id
|
||||
})
|
||||
break
|
||||
case 'approval.request':
|
||||
setState('prompt', { kind: 'approval', command: event.payload.command, description: event.payload.description })
|
||||
break
|
||||
case 'sudo.request':
|
||||
setState('prompt', { kind: 'sudo', requestId: event.payload.request_id })
|
||||
break
|
||||
case 'secret.request':
|
||||
setState('prompt', {
|
||||
kind: 'secret',
|
||||
envVar: event.payload.env_var,
|
||||
prompt: event.payload.prompt,
|
||||
requestId: event.payload.request_id
|
||||
})
|
||||
break
|
||||
// ── subagents (agents dashboard) — track the delegation tree by id ──
|
||||
case 'subagent.spawn_requested':
|
||||
case 'subagent.start':
|
||||
case 'subagent.thinking':
|
||||
case 'subagent.tool':
|
||||
case 'subagent.progress':
|
||||
case 'subagent.complete': {
|
||||
const id = readStr(event.payload, 'subagent_id')
|
||||
if (!id) break
|
||||
setState(
|
||||
produce(draft => {
|
||||
let sa = draft.subagents.find(s => s.id === id)
|
||||
if (!sa) {
|
||||
sa = { depth: readNum(event.payload, 'depth'), goal: '', id, status: 'running' }
|
||||
draft.subagents.push(sa)
|
||||
}
|
||||
const goal = readStr(event.payload, 'goal')
|
||||
if (goal) sa.goal = goal
|
||||
const model = readStr(event.payload, 'model')
|
||||
if (model) sa.model = model
|
||||
const parent = readStr(event.payload, 'parent_id')
|
||||
if (parent) sa.parentId = parent
|
||||
const summary = readStr(event.payload, 'summary')
|
||||
if (summary) sa.summary = summary
|
||||
const tool = readStr(event.payload, 'tool_name')
|
||||
if (tool) sa.lastTool = tool
|
||||
sa.status = readStr(event.payload, 'status') ?? subagentStatusFor(event.type)
|
||||
|
||||
// Live trace (item 15): a concise per-subagent activity log. Thinking
|
||||
// deltas update a transient `thought` (not appended — they'd flood).
|
||||
const text = readStr(event.payload, 'text')
|
||||
const trace = (sa.trace ??= [])
|
||||
if (event.type === 'subagent.start') trace.push(`▶ ${goal ?? sa.goal ?? 'started'}`)
|
||||
else if (event.type === 'subagent.tool' && tool) trace.push(`⚡ ${tool}${text ? ` — ${text}` : ''}`)
|
||||
else if (event.type === 'subagent.progress' && text) trace.push(text)
|
||||
else if (event.type === 'subagent.complete') trace.push(`✓ ${summary ?? 'done'}`)
|
||||
else if (event.type === 'subagent.thinking' && text) sa.thought = text
|
||||
if (trace.length > SUBAGENT_TRACE_LIMIT) trace.splice(0, trace.length - SUBAGENT_TRACE_LIMIT)
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
// ── gateway lifecycle / transport errors (auto-heal foundations) ──
|
||||
// The child exited mid-turn. THE key bug fix: clear the frozen `running`
|
||||
// spinner (no message.complete will ever arrive for the lost reply), tell
|
||||
// the user their in-flight reply was lost, and show a recovering status.
|
||||
case 'gateway.exited': {
|
||||
setState('info', prev => ({ ...prev, running: false }))
|
||||
// Neutral status: we don't ALWAYS recover (budget exhaustion). The
|
||||
// "recovering…" wording now comes from the gateway.recovering case,
|
||||
// which fires only when a respawn is actually scheduled.
|
||||
setState('status', 'gateway exited')
|
||||
const reason = event.payload?.reason
|
||||
const base = 'gateway exited — recovering your session (any in-flight reply was lost)'
|
||||
pushSystem(reason ? `${base}: ${reason}` : base)
|
||||
break
|
||||
}
|
||||
// A respawn+resume attempt is in flight — reflect the attempt in the status.
|
||||
case 'gateway.recovering': {
|
||||
const attempt = event.payload?.attempt
|
||||
setState('status', attempt ? `gateway recovering (attempt ${attempt})…` : 'gateway recovering…')
|
||||
break
|
||||
}
|
||||
// Collect stderr into a bounded ring (NOT the transcript) — see stderrRing.
|
||||
case 'gateway.stderr': {
|
||||
stderrRing.push(event.payload.line)
|
||||
if (stderrRing.length > STDERR_RING_LIMIT) stderrRing.splice(0, stderrRing.length - STDERR_RING_LIMIT)
|
||||
break
|
||||
}
|
||||
// The gateway never reached `gateway.ready` — surface the failure with any
|
||||
// stderr tail (payload is a loose Record; read defensively).
|
||||
case 'gateway.start_timeout': {
|
||||
const detail = readStr(event.payload, 'stderr') ?? readStr(event.payload, 'message') ?? stderrTail()
|
||||
pushSystem(detail ? `gateway failed to start:\n${detail}` : 'gateway failed to start')
|
||||
break
|
||||
}
|
||||
case 'gateway.protocol_error': {
|
||||
const preview = event.payload?.preview
|
||||
pushSystem(preview ? `gateway protocol error: ${preview}` : 'gateway protocol error')
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
const message = event.payload?.message
|
||||
pushSystem(message ? `error: ${message}` : 'error')
|
||||
break
|
||||
}
|
||||
// Other event types (chrome) are reduced in later phases; unhandled members
|
||||
// are intentionally ignored here.
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the active blocking prompt (after it's answered/cancelled). */
|
||||
function clearPrompt(): void {
|
||||
setState('prompt', undefined)
|
||||
}
|
||||
|
||||
// ── resume hydrate (opencode sync-v2): buffer live events while the snapshot
|
||||
// loads, then replace history + replay the buffer in order. Split into begin/
|
||||
// commit so the buffer can span an async `session.resume` RPC.
|
||||
/** Start buffering live events (call BEFORE the async resume RPC). Idempotent. */
|
||||
function beginBuffer(): void {
|
||||
if (!buffering) buffering = []
|
||||
}
|
||||
|
||||
/** Replace history with the resume snapshot, then replay events buffered meanwhile. */
|
||||
function commitSnapshot(snapshot: Message[]): void {
|
||||
// Slice to the cap BEFORE the first setState, not after. Yoga (WASM) layout
|
||||
// memory is grow-only, so even a TRANSIENT mount of an over-cap resume
|
||||
// snapshot would permanently ratchet the high-water mark — a set-then-trim
|
||||
// briefly hands the full fetched history to <For>. Pre-slicing guarantees
|
||||
// resuming ANY session mounts at most MESSAGE_CAP rows. (Events buffered
|
||||
// across the resume RPC, replayed below, self-cap via capMessages per push.)
|
||||
const capped = snapshot.length > MESSAGE_CAP ? snapshot.slice(-MESSAGE_CAP) : snapshot
|
||||
setState('messages', capped)
|
||||
// A resume is a fresh view → SET (not accumulate) the dropped count to what the
|
||||
// snapshot slice hid, so the notice reflects this session. Live pushes add to it.
|
||||
setState('dropped', snapshot.length - capped.length)
|
||||
const pending = buffering ?? []
|
||||
buffering = null
|
||||
for (const event of pending) applyNow(event)
|
||||
}
|
||||
|
||||
/** Synchronous convenience: buffer → load → commit (used by tests). */
|
||||
function hydrate(loadSnapshot: () => Message[]): void {
|
||||
beginBuffer()
|
||||
commitSnapshot(loadSnapshot())
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the loose `startup.catalog` response into the typed Catalog (item 9).
|
||||
* Decoded ONCE via `CatalogSchema` (decode-at-boundary); garbage decodes to
|
||||
* `Option.none` → the catalog is left unset rather than crashing the panel.
|
||||
*/
|
||||
function setCatalog(raw: unknown): void {
|
||||
const decoded = decodeCatalog(raw)
|
||||
if (Option.isNone(decoded)) return
|
||||
setState('catalog', catalogFrom(decoded.value))
|
||||
}
|
||||
|
||||
function setSessionId(sid: string | undefined): void {
|
||||
setState('sessionId', sid)
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
apply,
|
||||
pushUser,
|
||||
pushSystem,
|
||||
setCatalog,
|
||||
setSessionId,
|
||||
clearTranscript,
|
||||
setConfirm,
|
||||
openPager,
|
||||
closePager,
|
||||
openSwitcher,
|
||||
closeSwitcher,
|
||||
openPicker,
|
||||
closePicker,
|
||||
setCompletions,
|
||||
clearCompletions,
|
||||
applyInfo,
|
||||
setHint,
|
||||
openDashboard,
|
||||
closeDashboard,
|
||||
hydrate,
|
||||
beginBuffer,
|
||||
commitSnapshot,
|
||||
duplicate,
|
||||
clearPrompt
|
||||
} as const
|
||||
}
|
||||
|
||||
export type SessionStore = ReturnType<typeof createSessionStore>
|
||||
502
ui-opentui/src/logic/theme.ts
Normal file
502
ui-opentui/src/logic/theme.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Theme / skin engine (SOLID side, pure TS — spec v4 §7.5). A faithful 1:1 port
|
||||
* of Ink's `ui-tui/src/theme.ts` so EXISTING Hermes skins work UNCHANGED: same
|
||||
* `Theme`/`ThemeColors`/`ThemeBrand` shapes, same `DARK_THEME`/`LIGHT_THEME`
|
||||
* defaults, same `detectLightMode`, same Apple-Terminal ANSI-256 normalization,
|
||||
* and the same `fromSkin(colors, branding, …)` mapping + fallback chains.
|
||||
*
|
||||
* The view never hardcodes colors — it reads `theme.color.*` / `theme.brand.*`
|
||||
* via the ThemeProvider context (view/theme.tsx). The boundary feeds skins in
|
||||
* through `gateway.ready{payload.skin}` / `skin.changed` → fromSkin → the theme
|
||||
* signal.
|
||||
*
|
||||
* Source of truth for the contract: ui-tui/src/theme.ts (+ GatewaySkin in
|
||||
* ui-tui/src/gatewayTypes.ts). Keep this port in sync if that contract changes.
|
||||
*/
|
||||
|
||||
import { FALSE_RE, TRUE_RE } from './env.ts'
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string
|
||||
accent: string
|
||||
border: string
|
||||
text: string
|
||||
muted: string
|
||||
completionBg: string
|
||||
completionCurrentBg: string
|
||||
completionMetaBg: string
|
||||
completionMetaCurrentBg: string
|
||||
|
||||
label: string
|
||||
ok: string
|
||||
error: string
|
||||
warn: string
|
||||
|
||||
prompt: string
|
||||
sessionLabel: string
|
||||
sessionBorder: string
|
||||
|
||||
statusBg: string
|
||||
statusFg: string
|
||||
statusGood: string
|
||||
statusWarn: string
|
||||
statusBad: string
|
||||
statusCritical: string
|
||||
selectionBg: string
|
||||
|
||||
diffAdded: string
|
||||
diffRemoved: string
|
||||
diffAddedWord: string
|
||||
diffRemovedWord: string
|
||||
|
||||
shellDollar: string
|
||||
}
|
||||
|
||||
export interface ThemeBrand {
|
||||
name: string
|
||||
icon: string
|
||||
prompt: string
|
||||
welcome: string
|
||||
goodbye: string
|
||||
tool: string
|
||||
helpHeader: string
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
color: ThemeColors
|
||||
brand: ThemeBrand
|
||||
bannerLogo: string
|
||||
bannerHero: string
|
||||
}
|
||||
|
||||
/** The skin payload as emitted by the gateway (mirror ui-tui/src/gatewayTypes.ts GatewaySkin). */
|
||||
export interface GatewaySkin {
|
||||
banner_hero?: string
|
||||
banner_logo?: string
|
||||
branding?: Record<string, string>
|
||||
colors?: Record<string, string>
|
||||
help_header?: string
|
||||
tool_prefix?: string
|
||||
}
|
||||
|
||||
// ── Color math ───────────────────────────────────────────────────────
|
||||
|
||||
function parseHex(h: string): [number, number, number] | null {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(h)
|
||||
const hex = m?.[1]
|
||||
if (!hex) return null
|
||||
const n = parseInt(hex, 16)
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
|
||||
}
|
||||
|
||||
function mix(a: string, b: string, t: number) {
|
||||
const pa = parseHex(a)
|
||||
const pb = parseHex(b)
|
||||
if (!pa || !pb) return a
|
||||
const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t)
|
||||
return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1)
|
||||
}
|
||||
|
||||
const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const
|
||||
const ANSI_LIGHT_MAX_LUMINANCE = 0.72
|
||||
const ANSI_LIGHT_TARGET_LUMINANCE = 0.34
|
||||
const ANSI_LIGHT_MIN_SATURATION = 0.22
|
||||
const ANSI_MUTED_BUCKET = 245
|
||||
|
||||
const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [
|
||||
'text',
|
||||
'label',
|
||||
'ok',
|
||||
'error',
|
||||
'warn',
|
||||
'prompt',
|
||||
'statusFg',
|
||||
'statusGood',
|
||||
'statusWarn',
|
||||
'statusBad',
|
||||
'statusCritical',
|
||||
'shellDollar'
|
||||
]
|
||||
|
||||
const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder']
|
||||
|
||||
function xtermEightBitRgb(colorNumber: number): [number, number, number] {
|
||||
if (colorNumber >= 232) {
|
||||
const value = 8 + (colorNumber - 232) * 10
|
||||
return [value, value, value]
|
||||
}
|
||||
if (colorNumber >= 16) {
|
||||
const offset = colorNumber - 16
|
||||
// Indices are `% 6`, always within XTERM_6_LEVELS' bounds; `?? 0` only
|
||||
// satisfies noUncheckedIndexedAccess and is never actually reached.
|
||||
return [
|
||||
XTERM_6_LEVELS[Math.floor(offset / 36) % 6] ?? 0,
|
||||
XTERM_6_LEVELS[Math.floor(offset / 6) % 6] ?? 0,
|
||||
XTERM_6_LEVELS[offset % 6] ?? 0
|
||||
]
|
||||
}
|
||||
return [0, 0, 0]
|
||||
}
|
||||
|
||||
function channelLuminance(value: number): number {
|
||||
const normalized = value / 255
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function relativeLuminance(red: number, green: number, blue: number): number {
|
||||
return 0.2126 * channelLuminance(red) + 0.7152 * channelLuminance(green) + 0.0722 * channelLuminance(blue)
|
||||
}
|
||||
|
||||
function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
|
||||
const rn = red / 255
|
||||
const gn = green / 255
|
||||
const bn = blue / 255
|
||||
const max = Math.max(rn, gn, bn)
|
||||
const min = Math.min(rn, gn, bn)
|
||||
const lightness = (max + min) / 2
|
||||
if (max === min) return [0, 0, lightness]
|
||||
const delta = max - min
|
||||
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
||||
const hue =
|
||||
max === rn ? (gn - bn) / delta + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / delta + 2 : (rn - gn) / delta + 4
|
||||
return [hue / 6, saturation, lightness]
|
||||
}
|
||||
|
||||
function circularDistance(a: number, b: number): number {
|
||||
const distance = Math.abs(a - b)
|
||||
return Math.min(distance, 1 - distance)
|
||||
}
|
||||
|
||||
// Mirrors @hermes/ink's colorize.ts (kept local, like the Ink app copy).
|
||||
function richEightBitColorNumber(red: number, green: number, blue: number): number {
|
||||
const [, saturation, lightness] = rgbToHsl(red, green, blue)
|
||||
if (saturation < 0.15) {
|
||||
const gray = Math.round(lightness * 25)
|
||||
return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray
|
||||
}
|
||||
const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40
|
||||
const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40
|
||||
const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40
|
||||
return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue)
|
||||
}
|
||||
|
||||
function bestReadableAnsiColor(red: number, green: number, blue: number): number {
|
||||
const [hue, saturation, lightness] = rgbToHsl(red, green, blue)
|
||||
let bestColor = richEightBitColorNumber(red, green, blue)
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) {
|
||||
const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber)
|
||||
const candidateLuminance = relativeLuminance(candidateRed, candidateGreen, candidateBlue)
|
||||
if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) continue
|
||||
const [candidateHue, candidateSaturation, candidateLightness] = rgbToHsl(
|
||||
candidateRed,
|
||||
candidateGreen,
|
||||
candidateBlue
|
||||
)
|
||||
const saturationFloorPenalty =
|
||||
candidateSaturation < ANSI_LIGHT_MIN_SATURATION ? (ANSI_LIGHT_MIN_SATURATION - candidateSaturation) * 3 : 0
|
||||
const score =
|
||||
circularDistance(candidateHue, hue) * 4 +
|
||||
Math.abs(candidateSaturation - Math.max(ANSI_LIGHT_MIN_SATURATION, saturation)) * 0.8 +
|
||||
Math.abs(candidateLightness - Math.min(lightness, ANSI_LIGHT_TARGET_LUMINANCE)) * 2 +
|
||||
saturationFloorPenalty
|
||||
if (score < bestScore) {
|
||||
bestColor = colorNumber
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return bestColor
|
||||
}
|
||||
|
||||
function normalizeAnsiForeground(color: string): string {
|
||||
const rgb = parseHex(color)
|
||||
if (!rgb) return color
|
||||
const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2])
|
||||
const richRgb = xtermEightBitRgb(richAnsi)
|
||||
const ansi =
|
||||
relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE
|
||||
? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2])
|
||||
: richAnsi
|
||||
return `ansi256(${ansi})`
|
||||
}
|
||||
|
||||
// ── Defaults ─────────────────────────────────────────────────────────
|
||||
|
||||
const BRAND: ThemeBrand = {
|
||||
name: 'Hermes Agent',
|
||||
icon: '⚕',
|
||||
prompt: '❯',
|
||||
welcome: 'Type your message or /help for commands.',
|
||||
goodbye: 'Goodbye! ⚕',
|
||||
tool: '┊',
|
||||
helpHeader: '(^_^)? Commands'
|
||||
}
|
||||
|
||||
const cleanPromptSymbol = (s: string | undefined, fallback: string) => {
|
||||
const cleaned = String(s ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
color: {
|
||||
primary: '#FFD700',
|
||||
accent: '#FFBF00',
|
||||
border: '#CD7F32',
|
||||
text: '#FFF8DC',
|
||||
muted: '#CC9B1F',
|
||||
completionBg: '#1a1a2e',
|
||||
completionCurrentBg: '#333355',
|
||||
completionMetaBg: '#1a1a2e',
|
||||
completionMetaCurrentBg: '#333355',
|
||||
|
||||
label: '#DAA520',
|
||||
ok: '#4caf50',
|
||||
error: '#ef5350',
|
||||
warn: '#ffa726',
|
||||
|
||||
prompt: '#FFF8DC',
|
||||
sessionLabel: '#CC9B1F',
|
||||
sessionBorder: '#CC9B1F',
|
||||
|
||||
statusBg: '#1a1a2e',
|
||||
statusFg: '#C0C0C0',
|
||||
statusGood: '#8FBC8F',
|
||||
statusWarn: '#FFD700',
|
||||
statusBad: '#FF8C00',
|
||||
statusCritical: '#FF6B6B',
|
||||
selectionBg: '#3a3a55',
|
||||
|
||||
diffAdded: 'rgb(220,255,220)',
|
||||
diffRemoved: 'rgb(255,220,220)',
|
||||
diffAddedWord: 'rgb(36,138,61)',
|
||||
diffRemovedWord: 'rgb(207,34,46)',
|
||||
shellDollar: '#4dabf7'
|
||||
},
|
||||
brand: BRAND,
|
||||
bannerLogo: '',
|
||||
bannerHero: ''
|
||||
}
|
||||
|
||||
export const LIGHT_THEME: Theme = {
|
||||
color: {
|
||||
primary: '#8B6914',
|
||||
accent: '#A0651C',
|
||||
border: '#7A4F1F',
|
||||
text: '#3D2F13',
|
||||
muted: '#7A5A0F',
|
||||
completionBg: '#F5F5F5',
|
||||
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
|
||||
completionMetaBg: '#F5F5F5',
|
||||
completionMetaCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
|
||||
|
||||
label: '#7A5A0F',
|
||||
ok: '#2E7D32',
|
||||
error: '#C62828',
|
||||
warn: '#E65100',
|
||||
|
||||
prompt: '#2B2014',
|
||||
sessionLabel: '#7A5A0F',
|
||||
sessionBorder: '#7A5A0F',
|
||||
|
||||
statusBg: '#F5F5F5',
|
||||
statusFg: '#333333',
|
||||
statusGood: '#2E7D32',
|
||||
statusWarn: '#8B6914',
|
||||
statusBad: '#D84315',
|
||||
statusCritical: '#B71C1C',
|
||||
selectionBg: '#D4E4F7',
|
||||
|
||||
diffAdded: 'rgb(200,240,200)',
|
||||
diffRemoved: 'rgb(240,200,200)',
|
||||
diffAddedWord: 'rgb(27,94,32)',
|
||||
diffRemovedWord: 'rgb(183,28,28)',
|
||||
shellDollar: '#1565C0'
|
||||
},
|
||||
brand: BRAND,
|
||||
bannerLogo: '',
|
||||
bannerHero: ''
|
||||
}
|
||||
|
||||
const LIGHT_DEFAULT_TERM_PROGRAMS = new Set<string>(['Apple_Terminal'])
|
||||
|
||||
const LUMA_LIGHT_THRESHOLD = 0.6
|
||||
const HEX_3_RE = /^[0-9a-f]{3}$/
|
||||
const HEX_6_RE = /^[0-9a-f]{6}$/
|
||||
|
||||
function backgroundLuminance(raw: string): null | number {
|
||||
const v = raw.trim().toLowerCase()
|
||||
if (!v) return null
|
||||
const hex = v.startsWith('#') ? v.slice(1) : v
|
||||
let rgb: [number, number, number] | null = null
|
||||
if (HEX_6_RE.test(hex)) {
|
||||
rgb = [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
|
||||
} else if (HEX_3_RE.test(hex)) {
|
||||
// `charAt` always returns a string (vs index access, which is `string |
|
||||
// undefined` under noUncheckedIndexedAccess); the regex guarantees 3 chars.
|
||||
const r = hex.charAt(0)
|
||||
const g = hex.charAt(1)
|
||||
const b = hex.charAt(2)
|
||||
rgb = [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16)]
|
||||
}
|
||||
if (!rgb) return null
|
||||
const [r, g, b] = rgb
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
||||
}
|
||||
|
||||
/** Pick light vs dark with ordered, explainable env signals (mirror Ink). */
|
||||
export function detectLightMode(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS
|
||||
): boolean {
|
||||
const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()
|
||||
if (TRUE_RE.test(lightFlag)) return true
|
||||
if (FALSE_RE.test(lightFlag)) return false
|
||||
|
||||
const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase()
|
||||
if (themeFlag === 'light') return true
|
||||
if (themeFlag === 'dark') return false
|
||||
|
||||
const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '')
|
||||
if (bgHint !== null) return bgHint >= LUMA_LIGHT_THRESHOLD
|
||||
|
||||
const colorfgbg = (env.COLORFGBG ?? '').trim()
|
||||
if (colorfgbg) {
|
||||
const lastField = colorfgbg.split(';').at(-1) ?? ''
|
||||
if (/^\d+$/.test(lastField)) {
|
||||
const bg = Number(lastField)
|
||||
if (bg === 7 || bg === 15) return true
|
||||
if (bg >= 0 && bg < 16) return false
|
||||
}
|
||||
}
|
||||
|
||||
const termProgram = (env.TERM_PROGRAM ?? '').trim()
|
||||
return lightDefaultTermPrograms.has(termProgram)
|
||||
}
|
||||
|
||||
function shouldNormalizeAnsiLightTheme(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
isLight = detectLightMode(env)
|
||||
): boolean {
|
||||
const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase()
|
||||
const termProgram = (env.TERM_PROGRAM ?? '').trim()
|
||||
return termProgram === 'Apple_Terminal' && colorTerm !== 'truecolor' && colorTerm !== '24bit' && isLight
|
||||
}
|
||||
|
||||
export function normalizeThemeForAnsiLightTerminal(
|
||||
theme: Theme,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
isLight = detectLightMode(env)
|
||||
): Theme {
|
||||
if (!shouldNormalizeAnsiLightTheme(env, isLight)) return theme
|
||||
const color = { ...theme.color }
|
||||
for (const key of ANSI_NORMALIZED_FOREGROUNDS) color[key] = normalizeAnsiForeground(color[key])
|
||||
for (const key of ANSI_MUTED_FOREGROUNDS) color[key] = `ansi256(${ANSI_MUTED_BUCKET})`
|
||||
return { ...theme, color }
|
||||
}
|
||||
|
||||
const DEFAULT_LIGHT_MODE = detectLightMode()
|
||||
|
||||
export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal(
|
||||
DEFAULT_LIGHT_MODE ? LIGHT_THEME : DARK_THEME,
|
||||
process.env,
|
||||
DEFAULT_LIGHT_MODE
|
||||
)
|
||||
|
||||
// ── Skin → Theme ─────────────────────────────────────────────────────
|
||||
|
||||
export function fromSkin(
|
||||
colors: Record<string, string>,
|
||||
branding: Record<string, string>,
|
||||
bannerLogo = '',
|
||||
bannerHero = '',
|
||||
toolPrefix = '',
|
||||
helpHeader = ''
|
||||
): Theme {
|
||||
const d = DEFAULT_THEME
|
||||
const c = (k: string) => colors[k]
|
||||
const hasSkinColors = Object.keys(colors).length > 0
|
||||
|
||||
const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent
|
||||
const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent
|
||||
const muted = c('banner_dim') ?? d.color.muted
|
||||
const completionBg = c('completion_menu_bg') ?? d.color.completionBg
|
||||
|
||||
const completionCurrentBg =
|
||||
c('completion_menu_current_bg') ??
|
||||
(hasSkinColors ? mix(completionBg, bannerAccent, 0.25) : d.color.completionCurrentBg)
|
||||
|
||||
const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg
|
||||
const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg
|
||||
|
||||
return normalizeThemeForAnsiLightTerminal(
|
||||
{
|
||||
color: {
|
||||
primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
|
||||
accent,
|
||||
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
|
||||
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
|
||||
muted,
|
||||
completionBg,
|
||||
completionCurrentBg,
|
||||
completionMetaBg,
|
||||
completionMetaCurrentBg,
|
||||
|
||||
label: c('ui_label') ?? d.color.label,
|
||||
ok: c('ui_ok') ?? d.color.ok,
|
||||
error: c('ui_error') ?? d.color.error,
|
||||
warn: c('ui_warn') ?? d.color.warn,
|
||||
|
||||
prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
|
||||
sessionLabel: c('session_label') ?? muted,
|
||||
sessionBorder: c('session_border') ?? muted,
|
||||
|
||||
statusBg: d.color.statusBg,
|
||||
statusFg: d.color.statusFg,
|
||||
statusGood: c('ui_ok') ?? d.color.statusGood,
|
||||
statusWarn: c('ui_warn') ?? d.color.statusWarn,
|
||||
statusBad: d.color.statusBad,
|
||||
statusCritical: d.color.statusCritical,
|
||||
selectionBg:
|
||||
c('selection_bg') ??
|
||||
c('completion_menu_current_bg') ??
|
||||
(hasSkinColors ? completionCurrentBg : d.color.selectionBg),
|
||||
|
||||
diffAdded: d.color.diffAdded,
|
||||
diffRemoved: d.color.diffRemoved,
|
||||
diffAddedWord: d.color.diffAddedWord,
|
||||
diffRemovedWord: d.color.diffRemovedWord,
|
||||
shellDollar: c('shell_dollar') ?? d.color.shellDollar
|
||||
},
|
||||
|
||||
brand: {
|
||||
name: branding.agent_name ?? d.brand.name,
|
||||
icon: d.brand.icon,
|
||||
prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
|
||||
welcome: branding.welcome ?? d.brand.welcome,
|
||||
goodbye: branding.goodbye ?? d.brand.goodbye,
|
||||
tool: toolPrefix || d.brand.tool,
|
||||
helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader)
|
||||
},
|
||||
|
||||
bannerLogo,
|
||||
bannerHero
|
||||
},
|
||||
process.env,
|
||||
DEFAULT_LIGHT_MODE
|
||||
)
|
||||
}
|
||||
|
||||
/** Convenience: map a GatewaySkin payload straight to a Theme (defaults if empty). */
|
||||
export function themeFromSkin(skin: GatewaySkin | undefined): Theme {
|
||||
if (!skin) return DEFAULT_THEME
|
||||
return fromSkin(
|
||||
skin.colors ?? {},
|
||||
skin.branding ?? {},
|
||||
skin.banner_logo ?? '',
|
||||
skin.banner_hero ?? '',
|
||||
skin.tool_prefix ?? '',
|
||||
skin.help_header ?? ''
|
||||
)
|
||||
}
|
||||
122
ui-opentui/src/logic/toolOutput.ts
Normal file
122
ui-opentui/src/logic/toolOutput.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Pure text-shaping helpers for compact tool-result rendering (spec v4 §7 / §8).
|
||||
* No OpenTUI/Solid imports — just string work, trivially unit-testable. Ported
|
||||
* 1:1 from the React build's `engine/toolOutput.ts` (itself mirroring opencode's
|
||||
* `util/collapse-tool-output.ts` + the gateway tool-result JSON-envelope unwrap).
|
||||
*/
|
||||
|
||||
/** Result of collapsing tool output for the block render. */
|
||||
export interface Collapsed {
|
||||
lines: string[]
|
||||
/** How many trailing lines were dropped (0 when nothing was hidden). */
|
||||
hiddenLines: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
// CSI escape sequences (SGR colors, cursor, mouse). The gateway colors some
|
||||
// slash/notice text with raw ANSI for the Ink TUI, which interprets it; the
|
||||
// native `<text>` renders byte-for-byte, so those codes would leak as literal
|
||||
// glyphs. Strip them on display (item 8).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_CSI = /[\u001b\u009b]\[[0-9;:?<>=]*[ -/]*[@-~]/g
|
||||
/** Remove ANSI/SGR/mouse escape sequences so they don't render as literal text. */
|
||||
export function stripAnsi(s: string): string {
|
||||
return (s ?? '').replace(ANSI_CSI, '')
|
||||
}
|
||||
|
||||
/** Truncate a single line to `width` columns, adding an ellipsis when cut. */
|
||||
export function truncate(s: string, width: number): string {
|
||||
const w = Math.max(1, width)
|
||||
return s.length > w ? s.slice(0, Math.max(1, w - 1)) + '…' : s
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-double-escape gateway output that arrived with LITERAL `\n`/`\t` escapes
|
||||
* (some tool tails are repr'd, so newlines show as backslash-n — item 7 "ugly").
|
||||
* Conservative: only un-escapes when literal `\n` sequences OUTNUMBER real
|
||||
* newlines, so genuinely multi-line output (and code that legitimately contains
|
||||
* the two chars `\` + `n`) is left untouched.
|
||||
*/
|
||||
export function normalizeOutput(text: string): string {
|
||||
const real = (text.match(/\n/g) ?? []).length
|
||||
const literal = (text.match(/\\n/g) ?? []).length
|
||||
if (literal > real)
|
||||
return text
|
||||
.replace(/\\r\\n/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, ' ')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the gateway's tool-result JSON envelope so the view shows the actual
|
||||
* output, not the wrapper. Many tools return
|
||||
* `{"output": "...", "exit_code": 0, "error": null}`. If `raw` parses to such an
|
||||
* object, return its `output` (plus a compact error/exit suffix when the command
|
||||
* failed); otherwise return `raw` unchanged. (Gotcha §8 — strip the envelope.)
|
||||
*/
|
||||
/**
|
||||
* When the gateway tail-caps a LARGE result it serialises the whole
|
||||
* `{"output": "...", "exit_code": 0, "error": null}` envelope first, so the
|
||||
* surviving tail ends mid-string with the envelope close (`…", "exit_code": 0,
|
||||
* "error": null}`) — and, if the head survived, opens with `{"output": "`. The
|
||||
* fragment can't be JSON.parsed, so peel those affixes off conservatively (only
|
||||
* the exact gateway shape; real output won't end this way). Item 2 polish.
|
||||
*/
|
||||
const ENVELOPE_HEAD = /^\s*\{\s*"output"\s*:\s*"/
|
||||
const ENVELOPE_TAIL = /"\s*,\s*"exit_code"\s*:\s*-?\d+(?:\s*,\s*"error"\s*:\s*(?:null|"(?:[^"\\]|\\.)*"))?\s*\}\s*$/
|
||||
|
||||
function unwrapEnvelopeFragment(s: string): string {
|
||||
const tail = ENVELOPE_TAIL.test(s)
|
||||
const head = ENVELOPE_HEAD.test(s)
|
||||
if (!tail && !head) return s
|
||||
return s.replace(ENVELOPE_HEAD, '').replace(ENVELOPE_TAIL, '')
|
||||
}
|
||||
|
||||
export function stripToolEnvelope(raw: string): string {
|
||||
const s = (raw ?? '').trim()
|
||||
if (!s.startsWith('{')) return normalizeOutput(unwrapEnvelopeFragment(raw ?? ''))
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(s)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'output' in parsed) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
let out = typeof obj.output === 'string' ? obj.output : JSON.stringify(obj.output, null, 2)
|
||||
const err = obj.error
|
||||
const code = obj.exit_code
|
||||
if (typeof err === 'string' && err) out += `\n[error] ${err}`
|
||||
else if (typeof code === 'number' && code !== 0) out += `\n[exit ${code}]`
|
||||
return normalizeOutput(out)
|
||||
}
|
||||
} catch {
|
||||
// not parseable as a whole — maybe a tail-capped envelope fragment
|
||||
}
|
||||
return normalizeOutput(unwrapEnvelopeFragment(raw ?? ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway caps verbose tool output to a tail and PREFIXES a literal label
|
||||
* (`tui_gateway/server.py:_cap_tui_verbose_text`):
|
||||
* `[showing verbose tail; omitted 5 lines / 234 chars]\n<tail>`
|
||||
* `[showing verbose tail; omitted 512 chars]\n<tail>`
|
||||
* The raw label is neither useful nor pretty (item 2). Strip it off and hand the
|
||||
* view a tidy `omittedNote` ("5 lines / 234 chars") to render as a dim affordance.
|
||||
*/
|
||||
export function stripOmittedNote(text: string): { body: string; omittedNote?: string } {
|
||||
const s = (text ?? '').replace(/^\s+/, '')
|
||||
const match = s.match(/^\[showing verbose tail; omitted (.+?)\]\n/)
|
||||
if (!match) return { body: text ?? '' }
|
||||
return { body: s.slice(match[0].length), omittedNote: match[1] ?? '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse text to at most `maxLines` lines, each capped to `width` columns. The
|
||||
* view renders an overflow marker from `hiddenLines`; this stays pure (no marker).
|
||||
*/
|
||||
export function collapseToolOutput(text: string, maxLines: number, width: number): Collapsed {
|
||||
const all = (text ?? '').replace(/\s+$/, '').split('\n')
|
||||
const limit = Math.max(1, maxLines)
|
||||
const lines = all.slice(0, limit).map(l => truncate(l, width))
|
||||
const hiddenLines = Math.max(0, all.length - lines.length)
|
||||
return { hiddenLines, lines, truncated: hiddenLines > 0 }
|
||||
}
|
||||
94
ui-opentui/src/test/copy.test.ts
Normal file
94
ui-opentui/src/test/copy.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Assistant-text extraction helpers (the /copy command's logic). Pure functions:
|
||||
* pull the answer text out of a live (parts) or settled (.text) assistant turn,
|
||||
* excluding reasoning/tool parts; pick the n-th newest assistant response.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { assistantResponses, messageText, nthAssistantResponse } from '../logic/copy.ts'
|
||||
import type { Message } from '../logic/store.ts'
|
||||
|
||||
describe('messageText', () => {
|
||||
test('a live parts turn concatenates text parts; excludes reasoning/tool', () => {
|
||||
const m: Message = {
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
parts: [
|
||||
{ type: 'reasoning', id: 'p1', text: 'thinking…' },
|
||||
{ type: 'text', id: 'p2', text: 'Hello' },
|
||||
{ type: 'tool', id: 't1', name: 'bash', state: 'complete', resultText: 'ran' },
|
||||
{ type: 'text', id: 'p3', text: ' world' }
|
||||
]
|
||||
}
|
||||
expect(messageText(m)).toBe('Hello world')
|
||||
})
|
||||
|
||||
test('trims surrounding whitespace from concatenated text parts', () => {
|
||||
const m: Message = {
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
parts: [{ type: 'text', id: 'p1', text: ' spaced ' }]
|
||||
}
|
||||
expect(messageText(m)).toBe('spaced')
|
||||
})
|
||||
|
||||
test('a settled/resumed turn (no parts) returns .text', () => {
|
||||
const m: Message = { role: 'assistant', text: 'resumed answer' }
|
||||
expect(messageText(m)).toBe('resumed answer')
|
||||
})
|
||||
|
||||
test('empty parts array falls back to .text', () => {
|
||||
const m: Message = { role: 'assistant', text: 'flat body', parts: [] }
|
||||
expect(messageText(m)).toBe('flat body')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistantResponses', () => {
|
||||
test('picks only assistant rows, newest-first, non-empty', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', text: 'welcome' },
|
||||
{ role: 'user', text: 'hi' },
|
||||
{ role: 'assistant', text: 'first reply' },
|
||||
{ role: 'user', text: 'and?' },
|
||||
{ role: 'assistant', text: '', parts: [{ type: 'text', id: 'p1', text: 'second reply' }] }
|
||||
]
|
||||
expect(assistantResponses(messages)).toEqual(['second reply', 'first reply'])
|
||||
})
|
||||
|
||||
test('skips assistant rows that resolve to empty text', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'assistant', text: 'kept' },
|
||||
{ role: 'assistant', text: '', parts: [{ type: 'reasoning', id: 'r1', text: 'only thinking' }] }
|
||||
]
|
||||
expect(assistantResponses(messages)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
test('empty messages → []', () => {
|
||||
expect(assistantResponses([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nthAssistantResponse', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'assistant', text: 'oldest' },
|
||||
{ role: 'user', text: 'q' },
|
||||
{ role: 'assistant', text: 'newest' }
|
||||
]
|
||||
|
||||
test('n=1 is the last assistant response', () => {
|
||||
expect(nthAssistantResponse(messages, 1)).toBe('newest')
|
||||
})
|
||||
|
||||
test('n=2 is the previous assistant response', () => {
|
||||
expect(nthAssistantResponse(messages, 2)).toBe('oldest')
|
||||
})
|
||||
|
||||
test('n past the end → undefined', () => {
|
||||
expect(nthAssistantResponse(messages, 3)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('no assistant responses → undefined', () => {
|
||||
expect(nthAssistantResponse([{ role: 'user', text: 'hi' }], 1)).toBeUndefined()
|
||||
expect(nthAssistantResponse([], 1)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
31
ui-opentui/src/test/env.test.ts
Normal file
31
ui-opentui/src/test/env.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
|
||||
describe('envFlag', () => {
|
||||
test('recognizes truthy values regardless of case/whitespace', () => {
|
||||
for (const v of ['1', 'true', 'yes', 'on', 'TRUE', 'Yes', ' on ']) {
|
||||
expect(envFlag(v, false)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('recognizes falsy values regardless of case/whitespace', () => {
|
||||
for (const v of ['0', 'false', 'no', 'off', 'FALSE', 'No', ' off ']) {
|
||||
expect(envFlag(v, true)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('returns fallback when unset', () => {
|
||||
expect(envFlag(undefined, true)).toBe(true)
|
||||
expect(envFlag(undefined, false)).toBe(false)
|
||||
expect(envFlag('', true)).toBe(true)
|
||||
expect(envFlag(' ', false)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns fallback for unrecognized garbage', () => {
|
||||
expect(envFlag('maybe', true)).toBe(true)
|
||||
expect(envFlag('maybe', false)).toBe(false)
|
||||
expect(envFlag('2', true)).toBe(true)
|
||||
expect(envFlag('enabled', false)).toBe(false)
|
||||
})
|
||||
})
|
||||
43
ui-opentui/src/test/gateway.test.ts
Normal file
43
ui-opentui/src/test/gateway.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Phase 0 boundary test (spec v4 §5 Layer 1). Exercises the GatewayService shape
|
||||
* through the FakeGateway layer using @effect/vitest's `it.effect`: subscribe
|
||||
* receives emitted events; request records the call. Proves the Effect<->Solid
|
||||
* seam (subscribe) and the typed request path compile + run.
|
||||
*
|
||||
* `it.effect` runs the program in a scoped test runtime (TestClock + TestConsole
|
||||
* provided automatically), replacing the old hand-rolled ManagedRuntime shim.
|
||||
* The fake layer carries per-test controller state (we assert `controller.calls`),
|
||||
* so it's provided locally — the testing guide's allowed one-off, not a shared
|
||||
* `layer(...)` group.
|
||||
*/
|
||||
import { assert, describe, it } from '@effect/vitest'
|
||||
import { Effect } from 'effect'
|
||||
|
||||
import { GatewayService } from '../boundary/gateway/GatewayService.ts'
|
||||
import type { GatewayEvent } from '../boundary/schema/GatewayEvent.ts'
|
||||
import { fakeGatewayLayerWith, makeFakeGateway } from '../entry/fakeGateway.ts'
|
||||
|
||||
describe('GatewayService via FakeGateway (Phase 0)', () => {
|
||||
it.effect('subscribe receives emitted events; request records the call', () => {
|
||||
const controller = makeFakeGateway('sess-123')
|
||||
const received: GatewayEvent[] = []
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const gateway = yield* GatewayService
|
||||
const unsubscribe = yield* gateway.subscribe(event => received.push(event))
|
||||
// Emit after subscribing (synchronous fan-out in the fake).
|
||||
controller.emit({ type: 'gateway.ready' })
|
||||
controller.emit({ type: 'message.start' })
|
||||
yield* gateway.request('prompt.submit', { text: 'hi' })
|
||||
unsubscribe()
|
||||
controller.emit({ type: 'message.complete' }) // dropped: unsubscribed
|
||||
|
||||
assert.strictEqual(gateway.sessionId(), 'sess-123')
|
||||
assert.deepStrictEqual(
|
||||
received.map(e => e.type),
|
||||
['gateway.ready', 'message.start']
|
||||
)
|
||||
assert.deepStrictEqual(controller.calls, [{ method: 'prompt.submit', params: { text: 'hi' } }])
|
||||
}).pipe(Effect.provide(fakeGatewayLayerWith(controller)))
|
||||
})
|
||||
})
|
||||
76
ui-opentui/src/test/gatewayRecovery.test.ts
Normal file
76
ui-opentui/src/test/gatewayRecovery.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Recovery-budget policy test (LOGIC side, pure). The crash-loop bound: attempts
|
||||
* are capped within a sliding window, stale attempts are pruned, and recovery is
|
||||
* refused with no session. Plus opencode-style exponential backoff (1s→30s cap).
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
backoffMs,
|
||||
GATEWAY_RECOVERY_LIMIT,
|
||||
GATEWAY_RECOVERY_WINDOW_MS,
|
||||
planGatewayRecovery
|
||||
} from '../logic/gatewayRecovery.ts'
|
||||
|
||||
describe('planGatewayRecovery — crash-loop budget', () => {
|
||||
test('allows GATEWAY_RECOVERY_LIMIT attempts within the window, refuses the next', () => {
|
||||
const sid = 'sess-1'
|
||||
let attempts: number[] = []
|
||||
const now = 1_000_000
|
||||
|
||||
// The first LIMIT exits all recover, each recording its timestamp.
|
||||
for (let i = 0; i < GATEWAY_RECOVERY_LIMIT; i++) {
|
||||
const plan = planGatewayRecovery(sid, null, attempts, now + i)
|
||||
expect(plan.recover).toBe(true)
|
||||
expect(plan.sid).toBe(sid)
|
||||
attempts = plan.attempts
|
||||
}
|
||||
expect(attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
|
||||
|
||||
// The (LIMIT+1)th within the window is refused; attempts are NOT extended.
|
||||
const refused = planGatewayRecovery(sid, null, attempts, now + GATEWAY_RECOVERY_LIMIT)
|
||||
expect(refused.recover).toBe(false)
|
||||
expect(refused.attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
|
||||
})
|
||||
|
||||
test('prunes attempts older than GATEWAY_RECOVERY_WINDOW_MS, freeing the budget', () => {
|
||||
const sid = 'sess-1'
|
||||
const now = 1_000_000
|
||||
// Three stale attempts (all outside the window) + one fresh.
|
||||
const stale = [now - GATEWAY_RECOVERY_WINDOW_MS - 5, now - GATEWAY_RECOVERY_WINDOW_MS - 4, now - 30_000]
|
||||
const plan = planGatewayRecovery(sid, null, stale, now)
|
||||
// The two truly-stale ones are pruned; the in-window one survives + `now` added.
|
||||
expect(plan.recover).toBe(true)
|
||||
expect(plan.attempts).toEqual([now - 30_000, now])
|
||||
})
|
||||
|
||||
test('refuses recovery when there is no session id (live nor recover)', () => {
|
||||
const plan = planGatewayRecovery(null, null, [], 1_000_000)
|
||||
expect(plan.recover).toBe(false)
|
||||
expect(plan.sid).toBeNull()
|
||||
expect(plan.attempts).toEqual([])
|
||||
})
|
||||
|
||||
test('falls back to the recoverSid when the live sid was already cleared', () => {
|
||||
const plan = planGatewayRecovery(null, 'pending-sess', [], 1_000_000)
|
||||
expect(plan.recover).toBe(true)
|
||||
expect(plan.sid).toBe('pending-sess')
|
||||
})
|
||||
})
|
||||
|
||||
describe('backoffMs — exponential delay (1s→30s cap)', () => {
|
||||
test('doubles per attempt (1-based) and caps at 30000ms', () => {
|
||||
expect(backoffMs(1)).toBe(1000)
|
||||
expect(backoffMs(2)).toBe(2000)
|
||||
expect(backoffMs(3)).toBe(4000)
|
||||
expect(backoffMs(4)).toBe(8000)
|
||||
expect(backoffMs(5)).toBe(16000)
|
||||
expect(backoffMs(6)).toBe(30000) // 32000 clamped to the cap
|
||||
expect(backoffMs(10)).toBe(30000) // stays at the cap
|
||||
})
|
||||
|
||||
test('clamps a non-positive attempt to the first delay', () => {
|
||||
expect(backoffMs(0)).toBe(1000)
|
||||
expect(backoffMs(-3)).toBe(1000)
|
||||
})
|
||||
})
|
||||
60
ui-opentui/src/test/history.test.ts
Normal file
60
ui-opentui/src/test/history.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Prompt history (item 6) — pure cursor-cycling behaviour, no filesystem.
|
||||
* Up walks older, Down walks newer back to the stashed draft; push dedupes a
|
||||
* consecutive duplicate, persists, and resets the cursor; an edit (reset) puts
|
||||
* the next Up back at the newest. Per-directory file persistence is exercised
|
||||
* only via the injected `persist` sink here.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { createPromptHistory } from '../logic/history.ts'
|
||||
|
||||
describe('prompt history — cursor cycling', () => {
|
||||
test('Up walks older entries, Down walks back to the live draft', () => {
|
||||
const h = createPromptHistory({ initial: ['first', 'second', 'third'] })
|
||||
// start typing a draft, then press Up
|
||||
expect(h.prev('draft')).toBe('third')
|
||||
expect(h.prev('draft')).toBe('second')
|
||||
expect(h.prev('draft')).toBe('first')
|
||||
expect(h.prev('draft')).toBe('first') // clamped at the oldest
|
||||
// Down walks newer, then restores the stashed draft at the bottom
|
||||
expect(h.next()).toBe('second')
|
||||
expect(h.next()).toBe('third')
|
||||
expect(h.next()).toBe('draft')
|
||||
expect(h.next()).toBeNull() // already at the bottom
|
||||
})
|
||||
|
||||
test('push appends, dedupes a consecutive duplicate, persists, resets cursor', () => {
|
||||
const persisted: string[] = []
|
||||
const h = createPromptHistory({ initial: ['a'], persist: t => persisted.push(t) })
|
||||
h.push('b')
|
||||
h.push('b') // consecutive duplicate — not stored again
|
||||
h.push('c')
|
||||
expect(h.entries()).toEqual(['a', 'b', 'c'])
|
||||
expect(persisted).toEqual(['b', 'c'])
|
||||
// after push the cursor is at the bottom → Up returns the newest
|
||||
expect(h.prev('')).toBe('c')
|
||||
})
|
||||
|
||||
test('reset returns the cursor to the bottom (called on edit)', () => {
|
||||
const h = createPromptHistory({ initial: ['x', 'y'] })
|
||||
expect(h.prev('')).toBe('y')
|
||||
expect(h.prev('')).toBe('x')
|
||||
h.reset() // user edited the buffer
|
||||
expect(h.prev('newdraft')).toBe('y') // next Up starts from the newest again
|
||||
})
|
||||
|
||||
test('empty history: prev/next are inert', () => {
|
||||
const h = createPromptHistory()
|
||||
expect(h.prev('draft')).toBeNull()
|
||||
expect(h.next()).toBeNull()
|
||||
})
|
||||
|
||||
test('max cap drops the oldest entries', () => {
|
||||
const h = createPromptHistory({ max: 2 })
|
||||
h.push('1')
|
||||
h.push('2')
|
||||
h.push('3')
|
||||
expect(h.entries()).toEqual(['2', '3'])
|
||||
})
|
||||
})
|
||||
95
ui-opentui/src/test/lib/render.ts
Normal file
95
ui-opentui/src/test/lib/render.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* test/lib/render.ts — headless renderable verification helpers (spec v4 §5
|
||||
* Layer 2). Wraps the Solid binding's `testRender` + the settle dance.
|
||||
*
|
||||
* Settling needs care: Solid mounts async; a `<scrollbox>` needs a couple of
|
||||
* passes to measure content + apply stickyStart; and the native `<markdown>`
|
||||
* (Tree-sitter) tokenizes ASYNCHRONOUSLY — a plain `renderOnce` loop captures
|
||||
* before its text paints. So we `flush()` (wait until scheduled rendering
|
||||
* settles) between passes, and `captureFrame` can wait for specific content via
|
||||
* `until` (retries with `waitForFrame`) for markdown-bearing frames.
|
||||
*
|
||||
* `exitOnCtrlC: false` is forced (gotcha §8 #7 — the test renderer defaults true
|
||||
* and would tear down on the first simulated Ctrl+C, blanking later frames).
|
||||
*
|
||||
* Keymap (Phase 3): overlays/prompts register close layers via `@opentui/keymap`,
|
||||
* whose hooks throw without a `<KeymapProvider>`. The entry provides one in the
|
||||
* real app; here we provide a test keymap built from the test renderer (read via
|
||||
* `useRenderer()` inside the tree) so headless mounts of those views work.
|
||||
*/
|
||||
import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui'
|
||||
import { KeymapProvider } from '@opentui/keymap/solid'
|
||||
import { testRender, useRenderer } from '@opentui/solid'
|
||||
import type { JSX } from '@opentui/solid'
|
||||
import { createMemo } from 'solid-js'
|
||||
|
||||
/** Wrap a node in a KeymapProvider whose keymap is bound to the test renderer. */
|
||||
function withKeymap(node: () => JSX.Element): () => JSX.Element {
|
||||
return () => {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createMemo(() => createDefaultOpenTuiKeymap(renderer))
|
||||
return KeymapProvider({
|
||||
keymap: keymap(),
|
||||
get children() {
|
||||
return node()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface RenderProbe {
|
||||
readonly frame: () => string
|
||||
readonly waitForFrame: (predicate: (frame: string) => boolean) => Promise<string>
|
||||
readonly resize: (width: number, height: number) => void
|
||||
readonly destroy: () => void
|
||||
}
|
||||
|
||||
/** Mount a Solid node headlessly and return a probe with a settled first frame. */
|
||||
export async function renderProbe(
|
||||
node: () => JSX.Element,
|
||||
options?: { width?: number; height?: number }
|
||||
): Promise<RenderProbe> {
|
||||
const setup = await testRender(withKeymap(node), {
|
||||
width: options?.width ?? 80,
|
||||
height: options?.height ?? 24,
|
||||
exitOnCtrlC: false
|
||||
})
|
||||
// renderOnce → flush → renderOnce: flush awaits async work (scrollbox measure,
|
||||
// Tree-sitter markdown tokenization) that a single sync pass would miss. The
|
||||
// native `<markdown internalBlockMode="top-level">` commits blocks over several
|
||||
// native frames, so settle to visual idle too (best-effort).
|
||||
await setup.renderOnce()
|
||||
await setup.flush()
|
||||
await setup.waitForVisualIdle?.()
|
||||
await setup.renderOnce()
|
||||
await setup.flush()
|
||||
|
||||
return {
|
||||
frame: () => setup.captureCharFrame(),
|
||||
waitForFrame: predicate => setup.waitForFrame(predicate),
|
||||
resize: (width, height) => setup.resize(width, height),
|
||||
destroy: () => setup.renderer.destroy?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount, capture one settled frame, tear down. When `until` is given (string or
|
||||
* RegExp), waits for the frame to contain/match it first — use for async
|
||||
* markdown content that may not be painted on the first settled pass.
|
||||
*/
|
||||
export async function captureFrame(
|
||||
node: () => JSX.Element,
|
||||
options?: { width?: number; height?: number; until?: string | RegExp }
|
||||
): Promise<string> {
|
||||
const probe = await renderProbe(node, options)
|
||||
try {
|
||||
const until = options?.until
|
||||
if (until !== undefined) {
|
||||
const match = (frame: string) => (typeof until === 'string' ? frame.includes(until) : until.test(frame))
|
||||
return await probe.waitForFrame(match)
|
||||
}
|
||||
return probe.frame()
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
}
|
||||
71
ui-opentui/src/test/liveGateway.smoke.ts
Normal file
71
ui-opentui/src/test/liveGateway.smoke.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Phase 1 live transport smoke (spec v4 §5 Layer 4). Drives the REAL Python
|
||||
* `tui_gateway` through the GatewayService layer: spawn → gateway.ready →
|
||||
* session.create → (optional) prompt.submit → streamed reply. Asserts the
|
||||
* decode-once boundary + the handshake against the real server, NOT a fake.
|
||||
*
|
||||
* Skips gracefully when no Hermes python resolves (CI without the venv). Run
|
||||
* explicitly (no Bun):
|
||||
* node scripts/build.mjs src/test/liveGateway.smoke.ts .out
|
||||
* node --experimental-ffi --no-warnings .out/liveGateway.smoke.js
|
||||
* (or `bash scripts/acceptance.sh`, which runs it as the transport gate).
|
||||
*/
|
||||
import { Effect, ManagedRuntime } from 'effect'
|
||||
|
||||
import { GatewayService } from '../boundary/gateway/GatewayService.ts'
|
||||
import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
||||
import { getLog } from '../boundary/log.ts'
|
||||
import type { GatewayEvent } from '../boundary/schema/GatewayEvent.ts'
|
||||
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const log = getLog()
|
||||
const runtime = ManagedRuntime.make(liveGatewayLayer)
|
||||
const seen: GatewayEvent[] = []
|
||||
let ready = false
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const gateway = yield* GatewayService
|
||||
yield* gateway.subscribe(event => {
|
||||
seen.push(event)
|
||||
if (event.type === 'gateway.ready') ready = true
|
||||
})
|
||||
|
||||
// Wait for the unsolicited gateway.ready (handshake).
|
||||
const start = Date.now()
|
||||
while (!ready && Date.now() - start < READY_TIMEOUT_MS) {
|
||||
yield* Effect.promise(() => new Promise(r => setTimeout(r, 100)))
|
||||
}
|
||||
if (!ready) return { ok: false, why: 'no gateway.ready within timeout' }
|
||||
|
||||
// Create a session (NOT a long handler — responds inline).
|
||||
const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: 80 })
|
||||
const sid = created?.session_id ?? gateway.sessionId()
|
||||
if (!sid) return { ok: false, why: 'session.create returned no session_id' }
|
||||
|
||||
return { ok: true, sid, events: seen.length }
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await runtime.runPromise(program)
|
||||
if (result.ok) {
|
||||
console.log(`PASS — gateway.ready seen, session.create ok (sid=${result.sid}, events=${result.events})`)
|
||||
console.log(`log file: ${log.filePath}`)
|
||||
process.exitCode = 0
|
||||
} else {
|
||||
console.log(`FAIL — ${result.why}`)
|
||||
console.log('recent log:', JSON.stringify(log.tail(20), null, 2))
|
||||
process.exitCode = 1
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`TRANSPORT ERROR — ${error instanceof Error ? error.message : String(error)}`)
|
||||
console.log('recent log:', JSON.stringify(log.tail(20), null, 2))
|
||||
// Treat a missing python/model as a skip, not a hard fail, for CI parity.
|
||||
process.exitCode = 0
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
void main()
|
||||
111
ui-opentui/src/test/log.test.ts
Normal file
111
ui-opentui/src/test/log.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Log hardening (boundary/log.ts):
|
||||
* - safeStringify never throws on a circular ref / BigInt / hostile toJSON,
|
||||
* so one bad `data` payload can't flip `fileBroken` and kill file logging.
|
||||
* - the NDJSON file rotates by size (counter-driven), keeping disk bounded.
|
||||
* Rotation is exercised with a real temp dir; since LOG_MAX_BYTES (5 MiB) is not
|
||||
* exported, we seed the live file above the cap so the next write must rotate.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { Log, safeStringify } from '../boundary/log.ts'
|
||||
|
||||
describe('safeStringify', () => {
|
||||
test('handles a circular object without throwing', () => {
|
||||
const a: Record<string, unknown> = { name: 'a' }
|
||||
a.self = a
|
||||
const out = safeStringify(a)
|
||||
expect(typeof out).toBe('string')
|
||||
expect(out).toContain('[Circular]')
|
||||
expect(out).toContain('"name":"a"')
|
||||
})
|
||||
|
||||
test('handles a BigInt without throwing', () => {
|
||||
const out = safeStringify({ big: 10n, nested: { x: 9007199254740993n } })
|
||||
expect(typeof out).toBe('string')
|
||||
expect(out).toContain('"10n"')
|
||||
expect(out).toContain('"9007199254740993n"')
|
||||
})
|
||||
|
||||
test('handles a mixed circular + BigInt payload', () => {
|
||||
const node: Record<string, unknown> = { id: 1n }
|
||||
node.parent = node
|
||||
expect(() => safeStringify({ node, list: [1n, 2n] })).not.toThrow()
|
||||
})
|
||||
|
||||
test('degrades a hostile toJSON to a placeholder instead of throwing', () => {
|
||||
const hostile = {
|
||||
toJSON() {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}
|
||||
let out = ''
|
||||
expect(() => {
|
||||
out = safeStringify(hostile)
|
||||
}).not.toThrow()
|
||||
expect(typeof out).toBe('string')
|
||||
})
|
||||
|
||||
test('round-trips a plain object identically to JSON.stringify', () => {
|
||||
const v = { a: 1, b: 'two', c: [3, 4], d: null }
|
||||
expect(safeStringify(v)).toBe(JSON.stringify(v))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Log file logging survives a poison payload', () => {
|
||||
test('a circular/BigInt data field still writes a line and keeps filePath', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-log-poison-'))
|
||||
const file = join(dir, 'opentui-v2.log')
|
||||
try {
|
||||
const log = new Log(file, 'debug')
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
log.info('test', 'with circular', circular)
|
||||
log.info('test', 'with bigint', { n: 42n })
|
||||
// file logging must NOT be broken by the poison payloads
|
||||
expect(log.filePath).toBe(file)
|
||||
const lines = readFileLines(file)
|
||||
expect(lines.length).toBe(2)
|
||||
expect(lines[0]).toContain('[Circular]')
|
||||
expect(lines[1]).toContain('42n')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Log file rotation', () => {
|
||||
test('rotates the live file once it crosses the byte cap', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-log-rotate-'))
|
||||
const file = join(dir, 'opentui-v2.log')
|
||||
try {
|
||||
// Seed the live file ABOVE the 5 MiB cap so the very next write rotates.
|
||||
writeFileSync(file, 'x'.repeat(5 * 1024 * 1024 + 10) + '\n')
|
||||
const log = new Log(file, 'debug')
|
||||
log.info('test', 'first write after seed') // crosses the cap -> rotates
|
||||
log.info('test', 'second write on fresh file')
|
||||
|
||||
const names = readdirSync(dir).sort()
|
||||
expect(names).toContain('opentui-v2.log')
|
||||
expect(names).toContain('opentui-v2.log.1') // the seeded oversized file
|
||||
// The fresh live file holds the post-rotation writes, not the seed.
|
||||
const live = readFileLines(file)
|
||||
expect(live.length).toBe(2)
|
||||
expect(live[0]).toContain('first write after seed')
|
||||
// The rotated-out file is the big seed.
|
||||
const rotated = readFileLines(`${file}.1`)
|
||||
expect(rotated[0]?.startsWith('xxxx')).toBe(true)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function readFileLines(path: string): string[] {
|
||||
// trailing newline produces an empty tail we drop
|
||||
const text = readFileSync(path, 'utf8')
|
||||
return text.split('\n').filter(line => line.length > 0)
|
||||
}
|
||||
53
ui-opentui/src/test/pastes.test.ts
Normal file
53
ui-opentui/src/test/pastes.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Pasted-text store test — add returns a placeholder, expand restores the real
|
||||
* content, multiple pastes round-trip, unknown refs pass through, single-pass
|
||||
* replace keeps a self-referential paste safe. (input polish.)
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { createPasteStore, shouldPlaceholder } from '../logic/pastes.ts'
|
||||
|
||||
describe('createPasteStore', () => {
|
||||
test('add returns a numbered placeholder with the line count', () => {
|
||||
const s = createPasteStore()
|
||||
expect(s.add('a\nb\nc')).toBe('[Pasted text #1 +3 lines]')
|
||||
expect(s.add('single line')).toBe('[Pasted text #2]') // 1 line → no "+N lines"
|
||||
})
|
||||
|
||||
test('expand restores the real content for each ref', () => {
|
||||
const s = createPasteStore()
|
||||
const p1 = s.add('FIRST\nblock')
|
||||
const p2 = s.add('SECOND')
|
||||
const input = `before ${p1} middle ${p2} after`
|
||||
expect(s.expand(input)).toBe('before FIRST\nblock middle SECOND after')
|
||||
})
|
||||
|
||||
test('unknown ref is left as-is (e.g. user typed it, or it was cleared)', () => {
|
||||
const s = createPasteStore()
|
||||
expect(s.expand('look [Pasted text #99] here')).toBe('look [Pasted text #99] here')
|
||||
})
|
||||
|
||||
test('single-pass replace: a pasted block containing a ref literal is NOT re-expanded', () => {
|
||||
const s = createPasteStore()
|
||||
const p1 = s.add('code with [Pasted text #2] inside')
|
||||
s.add('SHOULD-NOT-APPEAR')
|
||||
// expanding the input replaces #1 with its content; the #2 inside that content
|
||||
// is not re-scanned, so SHOULD-NOT-APPEAR never leaks in.
|
||||
expect(s.expand(`x ${p1}`)).toBe('x code with [Pasted text #2] inside')
|
||||
})
|
||||
|
||||
test('clear drops stored pastes and resets ids', () => {
|
||||
const s = createPasteStore()
|
||||
const p = s.add('gone')
|
||||
s.clear()
|
||||
expect(s.expand(p)).toBe(p) // no longer expandable
|
||||
expect(s.add('fresh')).toBe('[Pasted text #1]') // seq reset
|
||||
})
|
||||
|
||||
test('shouldPlaceholder: ≥4 lines OR >400 chars', () => {
|
||||
expect(shouldPlaceholder('a\nb\nc\nd')).toBe(true) // 4 lines
|
||||
expect(shouldPlaceholder('a\nb\nc')).toBe(false) // 3 lines
|
||||
expect(shouldPlaceholder('x'.repeat(401))).toBe(true) // long
|
||||
expect(shouldPlaceholder('short')).toBe(false)
|
||||
})
|
||||
})
|
||||
332
ui-opentui/src/test/render.test.tsx
Normal file
332
ui-opentui/src/test/render.test.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Phase 1 render test (spec v4 §5 Layer 2). Mounts the App headlessly with a
|
||||
* store seeded by the scripted hello stream, asserts the captured frame is
|
||||
* THEMED (brand name/icon from the theme, not hardcoded), and that applying a
|
||||
* custom skin re-themes the brand name reactively.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { createSessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { captureFrame } from './lib/render.ts'
|
||||
|
||||
function seedHello(store: ReturnType<typeof createSessionStore>) {
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'message.delta', payload: { text: 'Hi there, glitch!' } })
|
||||
store.apply({ type: 'message.complete' })
|
||||
}
|
||||
|
||||
describe('App render (Phase 1, themed)', () => {
|
||||
test('renders the streamed hello + default brand into the frame', async () => {
|
||||
const store = createSessionStore()
|
||||
seedHello(store)
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'ready', width: 60, height: 16 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Hermes Agent') // default brand.name
|
||||
expect(frame).toContain('ready')
|
||||
expect(frame).toContain('Type your message') // composer placeholder (brand.welcome)
|
||||
// Assistant text renders through the native markdown renderable (<code filetype="markdown">,
|
||||
// drawUnstyledText:false → smooth live, but tree-sitter doesn't settle in the headless test
|
||||
// renderer; markdown paint is verified in the live smoke). Assert the data reached the store:
|
||||
const parts = store.state.messages.at(-1)?.parts ?? []
|
||||
expect(parts.some(p => p.type === 'text' && p.text === 'Hi there, glitch!')).toBe(true)
|
||||
})
|
||||
|
||||
test('applying a skin re-themes the brand name (skinnable, no hardcoding)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready', payload: { skin: { branding: { agent_name: 'Zephyr' } } } })
|
||||
seedHello(store)
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 60, height: 16 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Zephyr')
|
||||
expect(frame).not.toContain('Hermes Agent')
|
||||
})
|
||||
|
||||
test('renders an inline tool part between text (ordered parts §7)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'message.delta', payload: { text: 'Listing files:' } })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal' } })
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: { tool_id: 't1', result_text: '{"output":"alpha.txt\\nbeta.txt","exit_code":0}' }
|
||||
})
|
||||
store.apply({ type: 'message.complete' })
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'terminal', width: 60, height: 16 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('terminal') // tool name (inline, between text blocks)
|
||||
expect(frame).toContain('alpha.txt') // envelope-stripped output, block-rendered
|
||||
expect(frame).not.toContain('exit_code') // the {output,exit_code} envelope is stripped
|
||||
// the 'Listing files:' text part is markdown (live-rendered); assert it in the store:
|
||||
const parts = store.state.messages.at(-1)?.parts ?? []
|
||||
expect(parts.some(p => p.type === 'text' && p.text === 'Listing files:')).toBe(true)
|
||||
})
|
||||
|
||||
test('a tool part shows its primary-arg preview + duration in the collapsed header (item 2)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal', context: 'ls -la src' } })
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: {
|
||||
tool_id: 't1',
|
||||
name: 'terminal',
|
||||
args: { command: 'ls -la src' },
|
||||
duration_s: 0.3,
|
||||
result_text: 'alpha.txt\nbeta.txt'
|
||||
}
|
||||
})
|
||||
store.apply({ type: 'message.complete' })
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'ls -la src', width: 72, height: 16 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('terminal') // tool name
|
||||
expect(frame).toContain('ls -la src') // primary-arg preview (item 2 — args now visible)
|
||||
expect(frame).toContain('0.3s') // duration
|
||||
expect(frame).toContain('(2 lines)') // output line count (collapsed)
|
||||
})
|
||||
|
||||
test('a settled reasoning part collapses to a one-line "Thought: <title>" header (item 6)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'reasoning.delta', payload: { text: '**Weighing options**\n\nthe hidden body text here' } })
|
||||
store.apply({ type: 'message.delta', payload: { text: 'Answer.' } })
|
||||
store.apply({ type: 'message.complete' })
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Thought', width: 72, height: 16 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Thought') // settled → collapsed header label
|
||||
expect(frame).toContain('Weighing options') // the **bold** title is surfaced
|
||||
expect(frame).not.toContain('hidden body text') // collapsed → body not shown
|
||||
})
|
||||
|
||||
test('an approval prompt replaces the composer (blocked) and renders the options', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'approval.request', payload: { command: 'rm -rf /tmp/x', description: 'Delete temp dir' } })
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Approval required', width: 72, height: 24 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Approval required')
|
||||
expect(frame).toContain('rm -rf /tmp/x') // the command under review
|
||||
expect(frame).toContain('Approve once') // native <select> option
|
||||
expect(frame).toContain('Deny')
|
||||
expect(frame).not.toContain('Type your message') // composer is hidden while blocked
|
||||
})
|
||||
|
||||
test('the pager overlay renders title + content and replaces the transcript/composer', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.pushUser('a previous message')
|
||||
store.openPager('Status', 'status line one\nstatus line two\nstatus line three')
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Status', width: 72, height: 18 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Status') // pager title
|
||||
expect(frame).toContain('status line one') // paged content
|
||||
expect(frame).toContain('Esc/q close') // pager footer hint
|
||||
expect(frame).not.toContain('a previous message') // transcript replaced by the pager
|
||||
expect(frame).not.toContain('Type your message') // composer hidden while the pager is open
|
||||
})
|
||||
|
||||
test('the session switcher renders session rows and replaces the composer', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.openSwitcher([
|
||||
{ id: 's1', title: 'First chat', preview: 'hi', messageCount: 5 },
|
||||
{ id: 's2', title: 'Second chat', preview: 'yo', messageCount: 12 }
|
||||
])
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Resume a session', width: 72, height: 18 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Resume a session') // switcher header
|
||||
expect(frame).toContain('First chat') // session row
|
||||
expect(frame).toContain('Second chat')
|
||||
expect(frame).not.toContain('Type your message') // composer hidden while switcher open
|
||||
})
|
||||
|
||||
test('the composer shows a live slash-completions dropdown', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.setCompletions([
|
||||
{ display: '/compact', meta: 'compress context', text: '/compact' },
|
||||
{ display: '/clear', meta: '', text: '/clear' }
|
||||
])
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: '/compact', width: 72, height: 18 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('/compact') // candidate
|
||||
expect(frame).toContain('compress context') // its meta
|
||||
expect(frame).toContain('Tab complete') // dropdown hint
|
||||
})
|
||||
|
||||
test('the empty transcript shows the home hint (item 12)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Nous Research', width: 72, height: 20 }
|
||||
)
|
||||
|
||||
// (theme-independent assertions — testRender reuses a global root, so a prior
|
||||
// test's skin/brand can bleed; the real app has one store. The home hint's
|
||||
// content is what matters here.)
|
||||
expect(frame).toContain('Nous Research') // the tagline
|
||||
expect(frame).toContain('to mention') // the input tips line
|
||||
expect(frame).toContain('Ctrl+C to stop/quit')
|
||||
})
|
||||
|
||||
test('the home screen shows a collapsible tools/skills/MCP catalog panel (item 9)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.setCatalog({
|
||||
tools: { total: 42, toolsets: [{ name: 'core', count: 12 }] },
|
||||
skills: { total: 7, categories: [{ name: 'dev', count: 7 }] },
|
||||
mcp: { servers: ['railway', 'beeper'] }
|
||||
})
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: '42 tools', width: 72, height: 20 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('42 tools')
|
||||
expect(frame).toContain('7 skills')
|
||||
expect(frame).toContain('2 MCP') // mcp.servers.length
|
||||
})
|
||||
|
||||
test('the status bar renders model · context% · cwd (item 14)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({
|
||||
type: 'session.info',
|
||||
payload: {
|
||||
model: 'anthropic/claude-opus-4-8',
|
||||
cwd: '/tmp/proj',
|
||||
branch: 'main',
|
||||
usage: { context_percent: 42 }
|
||||
}
|
||||
})
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'claude-opus', width: 72, height: 18 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('claude-opus-4-8') // model (provider prefix trimmed)
|
||||
expect(frame).toContain('42%') // context usage percent
|
||||
expect(frame).toContain('/tmp/proj') // cwd
|
||||
expect(frame).toContain('main') // branch
|
||||
})
|
||||
|
||||
test('the agents dashboard renders the subagent tree and replaces the transcript', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.pushUser('parent turn')
|
||||
store.apply({
|
||||
type: 'subagent.start',
|
||||
payload: { subagent_id: 'a1', goal: 'research the topic', model: 'haiku', depth: 0 }
|
||||
})
|
||||
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search', text: 'opentui' } })
|
||||
store.openDashboard()
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Agents', width: 72, height: 24 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Agents') // dashboard header
|
||||
expect(frame).toContain('research the topic') // subagent goal (list + detail header)
|
||||
expect(frame).toContain('web_search') // last tool + live trace line (item 15)
|
||||
expect(frame).toContain('select') // footer hint "↑↓ select"
|
||||
expect(frame).not.toContain('parent turn') // transcript replaced by the dashboard
|
||||
})
|
||||
})
|
||||
64
ui-opentui/src/test/resume.test.ts
Normal file
64
ui-opentui/src/test/resume.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Resume mapper test (spec §1 lifecycle; gotcha §8 #5). The `session.resume`
|
||||
* history maps into the store's Message[], folding tool rows ({name,context},
|
||||
* NO text) into the preceding assistant turn's ordered parts so they render.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { mapResumeHistory } from '../logic/resume.ts'
|
||||
|
||||
describe('mapResumeHistory (Phase 4b)', () => {
|
||||
test('maps user/assistant text + folds tool rows into the preceding assistant parts', () => {
|
||||
const msgs = mapResumeHistory([
|
||||
{ role: 'user', text: 'list files' },
|
||||
{ role: 'assistant', text: 'Listing.' },
|
||||
{ role: 'tool', name: 'terminal', context: 'ls -la' },
|
||||
{ role: 'assistant', text: 'Done.' }
|
||||
])
|
||||
expect(msgs.map(m => m.role)).toEqual(['user', 'assistant', 'assistant'])
|
||||
expect(msgs[0]).toMatchObject({ role: 'user', text: 'list files' })
|
||||
|
||||
const a1 = msgs[1]!
|
||||
expect(a1.parts?.map(p => p.type)).toEqual(['text', 'tool']) // text + folded tool, inline
|
||||
const tool = a1.parts![1]!
|
||||
if (tool.type === 'tool') {
|
||||
// context → argsPreview (same field as a live tool part, so it renders identically)
|
||||
expect(tool).toMatchObject({ name: 'terminal', state: 'complete', argsPreview: 'ls -la' })
|
||||
} else {
|
||||
throw new Error('expected a folded tool part')
|
||||
}
|
||||
expect(msgs[2]).toMatchObject({ role: 'assistant', text: 'Done.' })
|
||||
})
|
||||
|
||||
test('a tool row with no preceding assistant gets a standalone assistant holder', () => {
|
||||
const msgs = mapResumeHistory([{ role: 'tool', name: 'read_file', context: 'foo.ts' }])
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0]!.role).toBe('assistant')
|
||||
expect(msgs[0]!.parts?.[0]).toMatchObject({ type: 'tool', name: 'read_file', argsPreview: 'foo.ts' })
|
||||
})
|
||||
|
||||
test('folds result_text + args so resumed tools render collapsible like live (item 1)', () => {
|
||||
const msgs = mapResumeHistory([
|
||||
{ role: 'assistant', text: 'Running.' },
|
||||
{
|
||||
role: 'tool',
|
||||
name: 'terminal',
|
||||
context: 'ls /usr/bin',
|
||||
args: { command: 'ls /usr/bin' },
|
||||
result_text: '[showing verbose tail; omitted 90 chars]\n{"output":"a\\nb\\nc","exit_code":0}'
|
||||
}
|
||||
])
|
||||
const tool = msgs[0]!.parts![1]!
|
||||
if (tool.type !== 'tool') throw new Error('expected a folded tool part')
|
||||
expect(tool.argsPreview).toBe('ls /usr/bin')
|
||||
expect(tool.resultText).toBe('a\nb\nc') // label peeled + envelope stripped → collapsible
|
||||
expect(tool.lineCount).toBe(3)
|
||||
expect(tool.omittedNote).toBe('90 chars')
|
||||
expect(tool.argsText).toContain('"command"')
|
||||
})
|
||||
|
||||
test('ignores non-arrays and unknown roles', () => {
|
||||
expect(mapResumeHistory(null)).toEqual([])
|
||||
expect(mapResumeHistory([{ role: 'weird', text: 'x' }])).toEqual([])
|
||||
})
|
||||
})
|
||||
78
ui-opentui/src/test/schema.test.ts
Normal file
78
ui-opentui/src/test/schema.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Phase 1 schema test (spec v4 §5 Layer 1/4). The gateway-contract decode: known
|
||||
* events decode with typed narrowing, unrecognized `type` and malformed payloads
|
||||
* are SKIPPED (Option.none) so a stray wire event never tears down the stream.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { Option, Schema } from 'effect'
|
||||
|
||||
import { GatewayEventSchema } from '../boundary/schema/GatewayEvent.ts'
|
||||
|
||||
const decode = Schema.decodeUnknownOption(GatewayEventSchema)
|
||||
|
||||
describe('GatewayEvent schema decode (Phase 1)', () => {
|
||||
test('decodes a known event with typed narrowing', () => {
|
||||
const ev = decode({ type: 'message.delta', payload: { text: 'hi' }, session_id: 's1' })
|
||||
expect(Option.isSome(ev)).toBe(true)
|
||||
if (Option.isSome(ev) && ev.value.type === 'message.delta') {
|
||||
expect(ev.value.payload?.text).toBe('hi')
|
||||
expect(ev.value.session_id).toBe('s1')
|
||||
}
|
||||
})
|
||||
|
||||
test('decodes gateway.ready carrying a skin', () => {
|
||||
const ev = decode({ type: 'gateway.ready', payload: { skin: { colors: { ui_primary: '#abc123' } } } })
|
||||
expect(Option.isSome(ev)).toBe(true)
|
||||
if (Option.isSome(ev) && ev.value.type === 'gateway.ready') {
|
||||
expect(ev.value.payload?.skin?.colors?.ui_primary).toBe('#abc123')
|
||||
}
|
||||
})
|
||||
|
||||
test('decodes the 4 blocking prompt requests', () => {
|
||||
expect(Option.isSome(decode({ type: 'clarify.request', payload: { question: '?', request_id: 'r' } }))).toBe(true)
|
||||
expect(Option.isSome(decode({ type: 'approval.request', payload: { command: 'rm', description: 'd' } }))).toBe(true)
|
||||
expect(Option.isSome(decode({ type: 'sudo.request', payload: { request_id: 'r' } }))).toBe(true)
|
||||
expect(
|
||||
Option.isSome(decode({ type: 'secret.request', payload: { env_var: 'X', prompt: 'p', request_id: 'r' } }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('decodes gateway.exited with and without payload fields', () => {
|
||||
const full = decode({ type: 'gateway.exited', payload: { reason: 'SIGKILL', code: 137, signal: 'SIGKILL' } })
|
||||
expect(Option.isSome(full)).toBe(true)
|
||||
if (Option.isSome(full) && full.value.type === 'gateway.exited') {
|
||||
expect(full.value.payload?.reason).toBe('SIGKILL')
|
||||
expect(full.value.payload?.code).toBe(137)
|
||||
expect(full.value.payload?.signal).toBe('SIGKILL')
|
||||
}
|
||||
// payload is optional in full
|
||||
const bare = decode({ type: 'gateway.exited' })
|
||||
expect(Option.isSome(bare)).toBe(true)
|
||||
if (Option.isSome(bare) && bare.value.type === 'gateway.exited') {
|
||||
expect(bare.value.payload).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('decodes gateway.recovering with and without payload fields', () => {
|
||||
const full = decode({ type: 'gateway.recovering', payload: { attempt: 2, delay_ms: 2000 } })
|
||||
expect(Option.isSome(full)).toBe(true)
|
||||
if (Option.isSome(full) && full.value.type === 'gateway.recovering') {
|
||||
expect(full.value.payload?.attempt).toBe(2)
|
||||
expect(full.value.payload?.delay_ms).toBe(2000)
|
||||
}
|
||||
const bare = decode({ type: 'gateway.recovering' })
|
||||
expect(Option.isSome(bare)).toBe(true)
|
||||
if (Option.isSome(bare) && bare.value.type === 'gateway.recovering') {
|
||||
expect(bare.value.payload).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('SKIPS an unrecognized event type (Option.none, no throw)', () => {
|
||||
expect(Option.isNone(decode({ type: 'totally.unknown.event', foo: 1 }))).toBe(true)
|
||||
})
|
||||
|
||||
test('SKIPS a malformed payload (missing required field)', () => {
|
||||
// clarify.request requires request_id
|
||||
expect(Option.isNone(decode({ type: 'clarify.request', payload: { question: '?' } }))).toBe(true)
|
||||
})
|
||||
})
|
||||
316
ui-opentui/src/test/slash.test.ts
Normal file
316
ui-opentui/src/test/slash.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Slash dispatch test (spec §5 Layer 3/4). Pure logic: parse + the dispatch
|
||||
* ladder (client → slash.exec → command.dispatch) against a fake SlashContext.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
dispatchSlash,
|
||||
mapCompletions,
|
||||
parseSlash,
|
||||
planCompletion,
|
||||
readReplaceFrom,
|
||||
type SlashContext
|
||||
} from '../logic/slash.ts'
|
||||
import type { PickerItem, SessionItem } from '../logic/store.ts'
|
||||
|
||||
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
|
||||
|
||||
describe('mapCompletions', () => {
|
||||
test('maps complete.slash items → candidates (display/meta default)', () => {
|
||||
expect(
|
||||
mapCompletions({ items: [{ display: '/compact', meta: 'compress', text: '/compact' }, { text: '/details' }] })
|
||||
).toEqual([
|
||||
{ display: '/compact', meta: 'compress', text: '/compact' },
|
||||
{ display: '/details', meta: '', text: '/details' }
|
||||
])
|
||||
expect(mapCompletions({ items: [] })).toEqual([])
|
||||
expect(mapCompletions(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('planCompletion (items 5 + 13)', () => {
|
||||
test('a slash line → complete.slash with the full text (name AND args)', () => {
|
||||
expect(planCompletion('/mod')).toEqual({ from: 0, method: 'complete.slash', params: { text: '/mod' } })
|
||||
// args too — the gateway completes e.g. /details section names
|
||||
expect(planCompletion('/details thi')).toEqual({
|
||||
from: 0,
|
||||
method: 'complete.slash',
|
||||
params: { text: '/details thi' }
|
||||
})
|
||||
})
|
||||
|
||||
test('a trailing path-like word → complete.path with that word + token start offset', () => {
|
||||
expect(planCompletion('explain @src/fo')).toEqual({
|
||||
from: 'explain '.length,
|
||||
method: 'complete.path',
|
||||
params: { word: '@src/fo' }
|
||||
})
|
||||
expect(planCompletion('cat ./rea')).toEqual({
|
||||
from: 'cat '.length,
|
||||
method: 'complete.path',
|
||||
params: { word: './rea' }
|
||||
})
|
||||
expect(planCompletion('open ~/proj')).toEqual({
|
||||
from: 'open '.length,
|
||||
method: 'complete.path',
|
||||
params: { word: '~/proj' }
|
||||
})
|
||||
})
|
||||
|
||||
test('plain prose / multiline → no completion', () => {
|
||||
expect(planCompletion('just some words')).toBeNull()
|
||||
expect(planCompletion('hello')).toBeNull()
|
||||
expect(planCompletion('/cmd with\nnewline')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readReplaceFrom', () => {
|
||||
test('reads gateway replace_from, falls back when absent/non-number', () => {
|
||||
expect(readReplaceFrom({ items: [], replace_from: 9 }, 0)).toBe(9)
|
||||
expect(readReplaceFrom({ items: [] }, 4)).toBe(4)
|
||||
expect(readReplaceFrom({ replace_from: 'nope' }, 7)).toBe(7)
|
||||
expect(readReplaceFrom(null, 2)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSlash', () => {
|
||||
test('splits name + arg; rejects non-slash / empty', () => {
|
||||
expect(parseSlash('/help')).toEqual({ name: 'help', arg: '' })
|
||||
expect(parseSlash('/model anthropic/claude')).toEqual({ name: 'model', arg: 'anthropic/claude' })
|
||||
expect(parseSlash('hello')).toBeNull()
|
||||
expect(parseSlash('/')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
interface Probe {
|
||||
ctx: SlashContext
|
||||
calls: Array<{ method: string; params: Record<string, unknown> }>
|
||||
system: string[]
|
||||
submitted: string[]
|
||||
confirmed: Array<{ message: string; onConfirm: () => void }>
|
||||
paged: Array<{ title: string; text: string }>
|
||||
switched: SessionItem[][]
|
||||
pickers: Array<{ title: string; items: PickerItem[]; onPick: (value: string) => void }>
|
||||
quit: { value: boolean }
|
||||
cleared: { value: boolean }
|
||||
dashboard: { value: boolean }
|
||||
copied: number[]
|
||||
copyN: { value: (n: number) => boolean }
|
||||
}
|
||||
|
||||
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
|
||||
const calls: Probe['calls'] = []
|
||||
const system: string[] = []
|
||||
const submitted: string[] = []
|
||||
const confirmed: Probe['confirmed'] = []
|
||||
const paged: Probe['paged'] = []
|
||||
const switched: Probe['switched'] = []
|
||||
const pickers: Probe['pickers'] = []
|
||||
const quit = { value: false }
|
||||
const cleared = { value: false }
|
||||
const dashboard = { value: false }
|
||||
const copied: number[] = []
|
||||
const copyN: Probe['copyN'] = { value: () => false }
|
||||
const ctx: SlashContext = {
|
||||
clearTranscript: () => (cleared.value = true),
|
||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||
copyResponse: n => {
|
||||
copied.push(n)
|
||||
return copyN.value(n)
|
||||
},
|
||||
listSessions: () => Promise.resolve(FAKE_SESSIONS),
|
||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||
openDashboard: () => (dashboard.value = true),
|
||||
openPager: (title, text) => paged.push({ text, title }),
|
||||
openPicker: p => pickers.push(p),
|
||||
openSwitcher: sessions => switched.push(sessions),
|
||||
pushSystem: text => system.push(text),
|
||||
quit: () => (quit.value = true),
|
||||
request: (method, params) => {
|
||||
calls.push({ method, params })
|
||||
return request(method, params)
|
||||
},
|
||||
sessionId: () => 'sid-1',
|
||||
submit: text => submitted.push(text)
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
cleared,
|
||||
confirmed,
|
||||
copied,
|
||||
copyN,
|
||||
ctx,
|
||||
dashboard,
|
||||
paged,
|
||||
pickers,
|
||||
quit,
|
||||
submitted,
|
||||
switched,
|
||||
system
|
||||
}
|
||||
}
|
||||
|
||||
describe('dispatchSlash — client commands', () => {
|
||||
test('/quit quits without hitting the gateway', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/quit', p.ctx)
|
||||
expect(p.quit.value).toBe(true)
|
||||
expect(p.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('/clear opens a confirm; running onConfirm clears the transcript', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/clear', p.ctx)
|
||||
expect(p.confirmed).toHaveLength(1)
|
||||
expect(p.cleared.value).toBe(false)
|
||||
p.confirmed[0]!.onConfirm()
|
||||
expect(p.cleared.value).toBe(true)
|
||||
})
|
||||
|
||||
test('/logs opens the pager with the recent ring lines', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/logs', p.ctx)
|
||||
expect(p.paged[0]?.title).toBe('Logs')
|
||||
expect(p.paged[0]?.text).toContain('session created')
|
||||
})
|
||||
|
||||
test('/sessions (and /resume) open the switcher with session.list rows', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/sessions', p.ctx)
|
||||
expect(p.switched).toHaveLength(1)
|
||||
expect(p.switched[0]).toEqual(FAKE_SESSIONS)
|
||||
const p2 = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/resume', p2.ctx)
|
||||
expect(p2.switched).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('/model (bare) opens a picker of authenticated providers’ models; pick switches', async () => {
|
||||
const p = makeCtx(async method => {
|
||||
if (method === 'model.options')
|
||||
return {
|
||||
model: 'claude-sonnet-4.6',
|
||||
providers: [
|
||||
{
|
||||
authenticated: true,
|
||||
models: ['claude-sonnet-4.6', 'claude-opus-4.6'],
|
||||
name: 'Anthropic',
|
||||
slug: 'anthropic'
|
||||
},
|
||||
{ authenticated: false, models: ['gpt-5.4'], name: 'OpenAI', slug: 'openai' }
|
||||
]
|
||||
}
|
||||
return { output: 'switched' }
|
||||
})
|
||||
await dispatchSlash('/model', p.ctx)
|
||||
expect(p.pickers).toHaveLength(1)
|
||||
expect(p.pickers[0]!.title).toBe('Switch model')
|
||||
// only the authenticated provider's models; current is marked
|
||||
expect(p.pickers[0]!.items.map(i => i.value)).toEqual(['claude-sonnet-4.6', 'claude-opus-4.6'])
|
||||
expect(p.pickers[0]!.items[0]!.label).toContain('✓')
|
||||
// picking switches via slash.exec `model <name>`
|
||||
p.pickers[0]!.onPick('claude-opus-4.6')
|
||||
await Promise.resolve()
|
||||
expect(p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model claude-opus-4.6')).toBe(true)
|
||||
})
|
||||
|
||||
test('/model <name> switches directly without opening the picker', async () => {
|
||||
const p = makeCtx(async () => ({ output: 'ok' }))
|
||||
await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx)
|
||||
expect(p.pickers).toHaveLength(0)
|
||||
expect(p.calls[0]).toEqual({
|
||||
method: 'slash.exec',
|
||||
params: { command: 'model anthropic/claude-opus-4.6', session_id: 'sid-1' }
|
||||
})
|
||||
})
|
||||
|
||||
test('/copy copies via copyResponse; no system line on success', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
p.copyN.value = () => true
|
||||
await dispatchSlash('/copy', p.ctx)
|
||||
expect(p.copied).toEqual([1])
|
||||
expect(p.system).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('/copy 2 passes the n-th index through', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
p.copyN.value = () => true
|
||||
await dispatchSlash('/copy 2', p.ctx)
|
||||
expect(p.copied).toEqual([2])
|
||||
})
|
||||
|
||||
test('/copy when nothing to copy pushes a system notice', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
p.copyN.value = () => false
|
||||
await dispatchSlash('/copy', p.ctx)
|
||||
expect(p.system).toContain('Nothing to copy yet.')
|
||||
})
|
||||
|
||||
test('/agents (and /tasks) open the agents dashboard', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/agents', p.ctx)
|
||||
expect(p.dashboard.value).toBe(true)
|
||||
const p2 = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/tasks', p2.ctx)
|
||||
expect(p2.dashboard.value).toBe(true)
|
||||
})
|
||||
|
||||
test('/skills opens a picker flattened from skills.manage list', async () => {
|
||||
const p = makeCtx(async method =>
|
||||
method === 'skills.manage' ? { skills: { media: ['ffmpeg', 'whisper'], web: ['firecrawl'] } } : {}
|
||||
)
|
||||
await dispatchSlash('/skills', p.ctx)
|
||||
expect(p.pickers).toHaveLength(1)
|
||||
expect(p.pickers[0]!.title).toBe('Skills')
|
||||
expect(p.pickers[0]!.items.map(i => i.value).sort()).toEqual(['ffmpeg', 'firecrawl', 'whisper'])
|
||||
})
|
||||
|
||||
test('/help renders the gateway catalog', async () => {
|
||||
const p = makeCtx(async method =>
|
||||
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
|
||||
)
|
||||
await dispatchSlash('/help', p.ctx)
|
||||
expect(p.calls[0]?.method).toBe('commands.catalog')
|
||||
expect(p.system.join('\n')).toContain('/model — switch model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchSlash — server ladder', () => {
|
||||
test('unknown command → slash.exec; SHORT output shown as a system line', async () => {
|
||||
const p = makeCtx(async method => (method === 'slash.exec' ? { output: 'all good' } : {}))
|
||||
await dispatchSlash('/status', p.ctx)
|
||||
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
|
||||
expect(p.system).toContain('all good')
|
||||
expect(p.paged).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('LONG slash.exec output opens the pager (titled by command)', async () => {
|
||||
const longText = Array.from({ length: 6 }, (_, i) => `output line ${i}`).join('\n')
|
||||
const p = makeCtx(async method => (method === 'slash.exec' ? { output: longText } : {}))
|
||||
await dispatchSlash('/status', p.ctx)
|
||||
expect(p.paged).toHaveLength(1)
|
||||
expect(p.paged[0]?.title).toBe('Status')
|
||||
expect(p.paged[0]?.text).toContain('output line 5')
|
||||
expect(p.system).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('slash.exec rejects → command.dispatch; send result submits a user turn', async () => {
|
||||
const p = makeCtx(async method => {
|
||||
if (method === 'slash.exec') throw new Error('not a worker command')
|
||||
if (method === 'command.dispatch') return { type: 'send', message: 'run the thing' }
|
||||
return {}
|
||||
})
|
||||
await dispatchSlash('/dothing', p.ctx)
|
||||
expect(p.calls.map(c => c.method)).toEqual(['slash.exec', 'command.dispatch'])
|
||||
expect(p.submitted).toEqual(['run the thing'])
|
||||
})
|
||||
|
||||
test('command.dispatch exec → system output', async () => {
|
||||
const p = makeCtx(async method => {
|
||||
if (method === 'slash.exec') throw new Error('reject')
|
||||
return { type: 'exec', output: 'done' }
|
||||
})
|
||||
await dispatchSlash('/whatever', p.ctx)
|
||||
expect(p.system).toContain('done')
|
||||
})
|
||||
})
|
||||
562
ui-opentui/src/test/store.test.ts
Normal file
562
ui-opentui/src/test/store.test.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Store test (spec v4 §5 Layer 3). Pure data behavior of the reducer: skin →
|
||||
* theme, LRU dedup, hydrate-while-buffering (Phase 1); and the Phase 2b ordered
|
||||
* `parts[]` model — text/tool interleave in one turn, tool start↔complete matched
|
||||
* by id and updated IN PLACE, `{output,exit_code}` envelope stripped.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { DEFAULT_THEME } from '../logic/theme.ts'
|
||||
import { createSessionStore, type Message } from '../logic/store.ts'
|
||||
|
||||
describe('session store — theming / dedup / hydrate (Phase 1)', () => {
|
||||
test('gateway.ready{skin} re-themes; default before', () => {
|
||||
const store = createSessionStore()
|
||||
expect(store.state.theme.brand.name).toBe(DEFAULT_THEME.brand.name)
|
||||
store.apply({
|
||||
type: 'gateway.ready',
|
||||
payload: { skin: { branding: { agent_name: 'Zephyr' }, colors: { ui_primary: '#123456' } } }
|
||||
})
|
||||
expect(store.state.ready).toBe(true)
|
||||
expect(store.state.theme.brand.name).toBe('Zephyr')
|
||||
expect(store.state.theme.color.primary).toBe('#123456')
|
||||
})
|
||||
|
||||
test('skin.changed updates the theme live', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'skin.changed', payload: { branding: { agent_name: 'Aurora' } } })
|
||||
expect(store.state.theme.brand.name).toBe('Aurora')
|
||||
})
|
||||
|
||||
test('LRU dedup: duplicate(id) returns false once, true after', () => {
|
||||
const store = createSessionStore()
|
||||
expect(store.duplicate('evt-1')).toBe(false)
|
||||
expect(store.duplicate('evt-1')).toBe(true)
|
||||
expect(store.duplicate(undefined)).toBe(false) // no id → never deduped
|
||||
})
|
||||
|
||||
test('hydrate replaces history, then replays events buffered mid-hydrate', () => {
|
||||
const store = createSessionStore()
|
||||
const snapshot: Message[] = [
|
||||
{ role: 'user', text: 'old q' },
|
||||
{ role: 'assistant', text: 'old a' }
|
||||
]
|
||||
// Simulate a live event arriving DURING hydrate by emitting inside loadSnapshot.
|
||||
let emittedDuring = false
|
||||
store.hydrate(() => {
|
||||
if (!emittedDuring) {
|
||||
emittedDuring = true
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'message.delta', payload: { text: 'live!' } })
|
||||
}
|
||||
return snapshot
|
||||
})
|
||||
// snapshot (2) + the buffered live assistant turn (1) replayed after
|
||||
expect(store.state.messages.length).toBe(3)
|
||||
expect(store.state.messages[0]!.text).toBe('old q')
|
||||
// the streamed assistant text now lives in an ordered text part
|
||||
expect(store.state.messages[2]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live!' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — ordered parts (Phase 2b)', () => {
|
||||
test('interleaves text → tool → text as ordered parts in one assistant turn', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'message.delta', payload: { text: 'before ' } })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal' } })
|
||||
// result_text is the {output,exit_code} JSON envelope — the store strips it.
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: { tool_id: 't1', result_text: '{"output":"hello\\nworld","exit_code":0}' }
|
||||
})
|
||||
store.apply({ type: 'message.delta', payload: { text: 'after' } })
|
||||
store.apply({ type: 'message.complete' })
|
||||
|
||||
const msg = store.state.messages.at(-1)!
|
||||
expect(msg.role).toBe('assistant')
|
||||
expect(msg.streaming).toBe(false)
|
||||
const parts = msg.parts ?? []
|
||||
expect(parts.map(p => p.type)).toEqual(['text', 'tool', 'text'])
|
||||
expect(parts[0]).toMatchObject({ type: 'text', text: 'before ' })
|
||||
expect(parts[2]).toMatchObject({ type: 'text', text: 'after' })
|
||||
const tool = parts[1]!
|
||||
if (tool.type === 'tool') {
|
||||
expect(tool.state).toBe('complete')
|
||||
expect(tool.name).toBe('terminal')
|
||||
expect(tool.resultText).toBe('hello\nworld') // envelope stripped
|
||||
expect(tool.lineCount).toBe(2)
|
||||
} else {
|
||||
throw new Error('expected a tool part at index 1')
|
||||
}
|
||||
})
|
||||
|
||||
test('message.complete with text but NO prior start creates the turn (complete-only gateway; no drop)', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
// no message.start / no deltas — straight to complete with the full text
|
||||
store.apply({ type: 'message.complete', payload: { text: 'The whole answer.' } })
|
||||
const msg = store.state.messages.at(-1)!
|
||||
expect(msg.role).toBe('assistant')
|
||||
expect(msg.streaming).toBe(false)
|
||||
expect(msg.parts?.some(p => p.type === 'text' && p.text === 'The whole answer.')).toBe(true)
|
||||
})
|
||||
|
||||
test('message.complete with no live turn and no text does NOT create an empty bubble', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.complete', payload: {} })
|
||||
expect(store.state.messages.filter(m => m.role === 'assistant')).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('tool.complete updates the running tool part IN PLACE (not a new row)', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'x', name: 'read_file' } })
|
||||
expect(store.state.messages.at(-1)!.parts).toHaveLength(1)
|
||||
expect(store.state.messages.at(-1)!.parts![0]).toMatchObject({ type: 'tool', state: 'running', name: 'read_file' })
|
||||
|
||||
store.apply({ type: 'tool.complete', payload: { tool_id: 'x', summary: 'read 42 lines' } })
|
||||
const parts = store.state.messages.at(-1)!.parts!
|
||||
expect(parts).toHaveLength(1) // updated in place — NOT appended as a separate row
|
||||
const tool = parts[0]!
|
||||
if (tool.type === 'tool') {
|
||||
expect(tool.state).toBe('complete')
|
||||
expect(tool.summary).toBe('read 42 lines')
|
||||
} else {
|
||||
throw new Error('expected a tool part')
|
||||
}
|
||||
})
|
||||
|
||||
test('captures tool args: context→argsPreview, args→argsText, duration, omitted note (item 2)', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'a', name: 'terminal', context: 'ls -la src' } })
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: {
|
||||
tool_id: 'a',
|
||||
name: 'terminal',
|
||||
args: { command: 'ls -la src' },
|
||||
duration_s: 0.34,
|
||||
result_text: '[showing verbose tail; omitted 3 lines / 90 chars]\nfile1\nfile2'
|
||||
}
|
||||
})
|
||||
const tool = store.state.messages.at(-1)!.parts![0]!
|
||||
if (tool.type !== 'tool') throw new Error('expected a tool part')
|
||||
expect(tool.argsPreview).toBe('ls -la src') // primary-arg preview shown in the header (NOT overwritten)
|
||||
expect(tool.argsText).toContain('"command"') // full args JSON for the expanded view
|
||||
expect(tool.duration).toBe(0.34)
|
||||
expect(tool.omittedNote).toBe('3 lines / 90 chars') // tidy note; raw label stripped
|
||||
expect(tool.resultText).toBe('file1\nfile2') // clean body (label peeled)
|
||||
expect(tool.lineCount).toBe(2)
|
||||
})
|
||||
|
||||
test('setCatalog maps the loose startup.catalog response defensively (item 9)', () => {
|
||||
const store = createSessionStore()
|
||||
store.setCatalog({
|
||||
tools: {
|
||||
total: 42,
|
||||
toolsets: [
|
||||
{ name: 'core', count: 12, enabled: true, tools: ['a', 'b', 3] },
|
||||
{ name: 'off', count: 5, enabled: false, tools: [] },
|
||||
{ name: '', count: 1 }
|
||||
]
|
||||
},
|
||||
skills: { total: 7, categories: [{ name: 'dev', count: 7 }] },
|
||||
mcp: { servers: ['railway', 123, 'beeper'] },
|
||||
junk: 'ignored'
|
||||
})
|
||||
const c = store.state.catalog!
|
||||
expect(c.tools.total).toBe(42)
|
||||
expect(c.tools.toolsets).toEqual([
|
||||
{ name: 'core', count: 12, enabled: true, tools: ['a', 'b'] }, // non-string tool dropped
|
||||
{ name: 'off', count: 5, enabled: false, tools: [] } // enabled flag preserved
|
||||
]) // nameless entry dropped
|
||||
expect(c.skills.total).toBe(7)
|
||||
expect(c.mcp.servers).toEqual(['railway', 'beeper']) // non-string dropped
|
||||
})
|
||||
|
||||
test('setCatalog leaves the catalog unset on garbage / non-object input (decode → none)', () => {
|
||||
const store = createSessionStore()
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
store.setCatalog('not an object')
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
store.setCatalog(null)
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
store.setCatalog(42)
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
})
|
||||
|
||||
test('setCatalog accepts a sparse but well-shaped catalog (absent sections default empty)', () => {
|
||||
const store = createSessionStore()
|
||||
store.setCatalog({ tools: { total: 3, toolsets: [{ name: 'core', count: 3, tools: ['a'] }] } })
|
||||
const c = store.state.catalog!
|
||||
expect(c.tools.total).toBe(3)
|
||||
expect(c.tools.toolsets).toEqual([{ name: 'core', count: 3, enabled: true, tools: ['a'] }]) // enabled defaults on
|
||||
expect(c.skills).toEqual({ total: 0, categories: [] }) // absent section → empty
|
||||
expect(c.mcp.servers).toEqual([])
|
||||
})
|
||||
|
||||
test('reasoning.delta accumulates into a reasoning part', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'reasoning.delta', payload: { text: 'thinking ' } })
|
||||
store.apply({ type: 'reasoning.delta', payload: { text: 'hard' } })
|
||||
const parts = store.state.messages.at(-1)!.parts ?? []
|
||||
expect(parts[0]).toMatchObject({ type: 'reasoning', text: 'thinking hard' })
|
||||
})
|
||||
|
||||
test('thinking.delta (kaomoji face) → transient status, NOT a transcript part; complete clears it', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'thinking.delta', payload: { text: '(´・_・`) formulating...' } })
|
||||
expect(store.state.status).toBe('(´・_・`) formulating...')
|
||||
expect(store.state.messages.at(-1)!.parts ?? []).toHaveLength(0) // no reasoning row from the face
|
||||
store.apply({ type: 'message.delta', payload: { text: 'Hi!' } })
|
||||
store.apply({ type: 'message.complete' })
|
||||
expect(store.state.status).toBeUndefined() // cleared when the turn ends
|
||||
// only the real reply text part remains — the face never entered the transcript
|
||||
expect((store.state.messages.at(-1)!.parts ?? []).map(p => p.type)).toEqual(['text'])
|
||||
})
|
||||
|
||||
test('status.update also drives the transient status line', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'status.update', payload: { kind: 'tool', text: 'running terminal…' } })
|
||||
expect(store.state.status).toBe('running terminal…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — blocking prompts (Phase 3)', () => {
|
||||
test('approval.request sets an approval prompt; clearPrompt clears it', () => {
|
||||
const store = createSessionStore()
|
||||
expect(store.state.prompt).toBeUndefined()
|
||||
store.apply({ type: 'approval.request', payload: { command: 'rm -rf /tmp/x', description: 'delete temp' } })
|
||||
expect(store.state.prompt).toMatchObject({ kind: 'approval', command: 'rm -rf /tmp/x', description: 'delete temp' })
|
||||
store.clearPrompt()
|
||||
expect(store.state.prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test('clarify.request carries question + choices + request_id', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'clarify.request', payload: { question: 'Which?', choices: ['a', 'b'], request_id: 'r1' } })
|
||||
const p = store.state.prompt
|
||||
expect(p).toMatchObject({ kind: 'clarify', question: 'Which?', requestId: 'r1' })
|
||||
if (p?.kind === 'clarify') expect(p.choices).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
test('clarify.request with null choices → free-text only', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'clarify.request', payload: { question: 'Name?', choices: null, request_id: 'r2' } })
|
||||
const p = store.state.prompt
|
||||
if (p?.kind === 'clarify') expect(p.choices).toBeNull()
|
||||
})
|
||||
|
||||
test('sudo.request + secret.request set masked prompts', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'sudo.request', payload: { request_id: 's1' } })
|
||||
expect(store.state.prompt).toMatchObject({ kind: 'sudo', requestId: 's1' })
|
||||
store.apply({ type: 'secret.request', payload: { env_var: 'API_KEY', prompt: 'Enter key', request_id: 's2' } })
|
||||
expect(store.state.prompt).toMatchObject({ kind: 'secret', envVar: 'API_KEY', requestId: 's2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — subagents (Phase 5e agents dashboard)', () => {
|
||||
test('subagent.* events build + update a subagent by id', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({
|
||||
type: 'subagent.start',
|
||||
payload: { subagent_id: 'a1', goal: 'research X', model: 'haiku', depth: 1 }
|
||||
})
|
||||
expect(store.state.subagents).toHaveLength(1)
|
||||
expect(store.state.subagents[0]).toMatchObject({ id: 'a1', goal: 'research X', status: 'running', depth: 1 })
|
||||
|
||||
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search' } })
|
||||
expect(store.state.subagents[0]).toMatchObject({ status: 'tool', lastTool: 'web_search' })
|
||||
|
||||
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'found it' } })
|
||||
expect(store.state.subagents).toHaveLength(1) // updated in place
|
||||
expect(store.state.subagents[0]).toMatchObject({ status: 'complete', summary: 'found it' })
|
||||
})
|
||||
|
||||
test('accumulates a live trace per subagent (item 15) + transient thought', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'crunch data' } })
|
||||
store.apply({ type: 'subagent.thinking', payload: { subagent_id: 'a1', text: 'considering options' } })
|
||||
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search', text: 'opentui' } })
|
||||
store.apply({ type: 'subagent.progress', payload: { subagent_id: 'a1', text: 'found 3 hits' } })
|
||||
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'done crunching' } })
|
||||
const sa = store.state.subagents[0]!
|
||||
// thinking text is transient (not in the trace), the rest is a concise log
|
||||
expect(sa.thought).toBe('considering options')
|
||||
expect(sa.trace).toEqual(['▶ crunch data', '⚡ web_search — opentui', 'found 3 hits', '✓ done crunching'])
|
||||
})
|
||||
|
||||
test('clearTranscript also clears subagents', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'g' } })
|
||||
store.clearTranscript()
|
||||
expect(store.state.subagents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — session chrome / status bar (item 14)', () => {
|
||||
test('session.info populates model/effort/cwd/branch and nested usage context', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({
|
||||
type: 'session.info',
|
||||
payload: {
|
||||
model: 'anthropic/claude-opus-4-8',
|
||||
reasoning_effort: 'high',
|
||||
fast: true,
|
||||
cwd: '/home/x/proj',
|
||||
branch: 'main',
|
||||
running: false,
|
||||
usage: { context_used: 42000, context_max: 200000, context_percent: 21 }
|
||||
}
|
||||
})
|
||||
const info = store.state.info
|
||||
expect(info.model).toBe('anthropic/claude-opus-4-8')
|
||||
expect(info.effort).toBe('high')
|
||||
expect(info.fast).toBe(true)
|
||||
expect(info.cwd).toBe('/home/x/proj')
|
||||
expect(info.branch).toBe('main')
|
||||
expect(info.contextPercent).toBe(21)
|
||||
expect(info.contextMax).toBe(200000)
|
||||
})
|
||||
|
||||
test('session.info reads context from TOP-LEVEL fields when there is no nested usage', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({
|
||||
type: 'session.info',
|
||||
payload: { model: 'gpt-5.4', context_used: 1000, context_max: 8000, context_percent: 13, compressions: 2 }
|
||||
})
|
||||
const info = store.state.info
|
||||
expect(info.model).toBe('gpt-5.4')
|
||||
expect(info.contextUsed).toBe(1000)
|
||||
expect(info.contextMax).toBe(8000)
|
||||
expect(info.contextPercent).toBe(13)
|
||||
expect(info.compressions).toBe(2)
|
||||
})
|
||||
|
||||
test('session.info prefers nested usage.context_* over the top-level fallback', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({
|
||||
type: 'session.info',
|
||||
payload: { context_percent: 5, usage: { context_percent: 88 } }
|
||||
})
|
||||
expect(store.state.info.contextPercent).toBe(88) // nested wins
|
||||
})
|
||||
|
||||
test('session.info with a malformed payload does NOT crash and leaves chrome untouched (decode → none)', () => {
|
||||
const store = createSessionStore()
|
||||
store.applyInfo({ model: 'opus', cwd: '/p' })
|
||||
// a wrong-typed field (model: number) fails the schema → empty patch, prior info survives
|
||||
store.apply({ type: 'session.info', payload: { model: 123, usage: 'nope' } })
|
||||
expect(store.state.info).toMatchObject({ model: 'opus', cwd: '/p' })
|
||||
})
|
||||
|
||||
test('session.info with a partial payload only patches the present fields', () => {
|
||||
const store = createSessionStore()
|
||||
store.applyInfo({ model: 'opus', branch: 'main', running: true })
|
||||
store.apply({ type: 'session.info', payload: { branch: 'dev' } }) // only branch present
|
||||
expect(store.state.info).toMatchObject({ model: 'opus', branch: 'dev', running: true })
|
||||
})
|
||||
|
||||
test('message.start sets running, message.complete clears it + refreshes usage', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
expect(store.state.info.running).toBe(true)
|
||||
store.apply({ type: 'message.delta', payload: { text: 'hi' } })
|
||||
store.apply({ type: 'message.complete', payload: { usage: { context_percent: 33 } } })
|
||||
expect(store.state.info.running).toBe(false)
|
||||
expect(store.state.info.contextPercent).toBe(33)
|
||||
})
|
||||
|
||||
test('applyInfo merges a session.create info patch without clobbering prior fields', () => {
|
||||
const store = createSessionStore()
|
||||
store.applyInfo({ model: 'gpt-5.4', cwd: '/tmp' })
|
||||
store.applyInfo({ branch: 'dev' }) // partial patch — model/cwd must survive
|
||||
expect(store.state.info).toMatchObject({ model: 'gpt-5.4', cwd: '/tmp', branch: 'dev' })
|
||||
})
|
||||
|
||||
test('setHint sets/clears the transient composer hint (Ctrl+C again to quit — item 11)', () => {
|
||||
const store = createSessionStore()
|
||||
expect(store.state.hint).toBeUndefined()
|
||||
store.setHint('Ctrl+C again to quit')
|
||||
expect(store.state.hint).toBe('Ctrl+C again to quit')
|
||||
store.setHint(undefined)
|
||||
expect(store.state.hint).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — gateway lifecycle / transport errors (auto-heal foundations)', () => {
|
||||
test('gateway.exited clears the frozen running spinner AND pushes a system notice', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
expect(store.state.info.running).toBe(true) // a turn is in flight
|
||||
store.apply({ type: 'gateway.exited' })
|
||||
// THE key bug fix: the spinner is cleared even though no message.complete arrived.
|
||||
expect(store.state.info.running).toBe(false)
|
||||
// Neutral status — "recovering…" now comes from gateway.recovering only.
|
||||
expect(store.state.status).toBe('gateway exited')
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys).toHaveLength(1)
|
||||
expect(sys[0]!.text).toContain('in-flight reply was lost')
|
||||
})
|
||||
|
||||
test('gateway.exited enriches the notice with payload.reason when present', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.exited', payload: { reason: 'SIGKILL', code: 137 } })
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys[0]!.text).toContain('SIGKILL')
|
||||
})
|
||||
|
||||
test('gateway.recovering reflects the attempt number in the status', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.recovering', payload: { attempt: 2 } })
|
||||
expect(store.state.status).toBe('gateway recovering (attempt 2)…')
|
||||
})
|
||||
|
||||
test('gateway.stderr is collected (NOT pushed to transcript), surfaced on start_timeout', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.stderr', payload: { line: 'ModuleNotFoundError: no module foo' } })
|
||||
store.apply({ type: 'gateway.stderr', payload: { line: 'traceback line 2' } })
|
||||
// chatty stderr never floods the transcript on its own
|
||||
expect(store.state.messages).toHaveLength(0)
|
||||
// …but the tail is surfaced when the gateway fails to start
|
||||
store.apply({ type: 'gateway.start_timeout', payload: {} })
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys).toHaveLength(1)
|
||||
expect(sys[0]!.text).toContain('gateway failed to start')
|
||||
expect(sys[0]!.text).toContain('ModuleNotFoundError')
|
||||
})
|
||||
|
||||
test('gateway.protocol_error and error are surfaced to the transcript', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.protocol_error', payload: { preview: '<garbled>' } })
|
||||
store.apply({ type: 'error', payload: { message: 'boom' } })
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys.map(m => m.text)).toEqual(['gateway protocol error: <garbled>', 'error: boom'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — resume hydrate (Phase 4b)', () => {
|
||||
test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => {
|
||||
const store = createSessionStore()
|
||||
store.beginBuffer()
|
||||
// a live event arrives DURING the (async) session.resume RPC
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'message.delta', payload: { text: 'live during resume' } })
|
||||
// the snapshot commits afterwards
|
||||
store.commitSnapshot([{ role: 'user', text: 'old question' }])
|
||||
expect(store.state.messages).toHaveLength(2) // snapshot(1) + the replayed assistant turn(1)
|
||||
expect(store.state.messages[0]).toMatchObject({ role: 'user', text: 'old question' })
|
||||
expect(store.state.messages[1]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live during resume' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — rolling message cap (bounds the Yoga node high-water mark)', () => {
|
||||
const ENV_KEY = 'HERMES_TUI_MAX_MESSAGES'
|
||||
const prev = process.env[ENV_KEY]
|
||||
afterEach(() => {
|
||||
if (prev === undefined) delete process.env[ENV_KEY]
|
||||
else process.env[ENV_KEY] = prev
|
||||
})
|
||||
|
||||
test('caps the message array at the env-tuned MESSAGE_CAP, dropping the oldest (head)', () => {
|
||||
process.env[ENV_KEY] = '5'
|
||||
const store = createSessionStore()
|
||||
// push more than the cap; each distinct so we can tell which survived
|
||||
for (let i = 0; i < 55; i++) store.pushUser(`msg ${i}`)
|
||||
expect(store.state.messages).toHaveLength(5)
|
||||
expect(store.state.dropped).toBe(50) // head-sliced overflow is counted for the notice
|
||||
// the oldest 50 were sliced from the head; survivors are the last 5 (msg 50..54)
|
||||
expect(store.state.messages[0]!.text).toBe('msg 50')
|
||||
expect(store.state.messages.at(-1)!.text).toBe('msg 54')
|
||||
})
|
||||
|
||||
test('pushSystem is also capped (head-dropped) at MESSAGE_CAP', () => {
|
||||
process.env[ENV_KEY] = '3'
|
||||
const store = createSessionStore()
|
||||
for (let i = 0; i < 10; i++) store.pushSystem(`sys ${i}`)
|
||||
expect(store.state.messages).toHaveLength(3)
|
||||
expect(store.state.messages[0]!.text).toBe('sys 7')
|
||||
expect(store.state.messages.at(-1)!.text).toBe('sys 9')
|
||||
})
|
||||
|
||||
test('the in-flight streaming turn it opens at overflow SURVIVES the cap (head sliced, not tail)', () => {
|
||||
process.env[ENV_KEY] = '4'
|
||||
const store = createSessionStore()
|
||||
// fill to the cap with user rows so the next push overflows
|
||||
store.pushUser('u0')
|
||||
store.pushUser('u1')
|
||||
store.pushUser('u2')
|
||||
store.pushUser('u3') // array now at the cap (4): [u0, u1, u2, u3]
|
||||
expect(store.state.messages).toHaveLength(4)
|
||||
|
||||
// message.start pushes the assistant turn as the LAST row (length 5) → head sliced to 4.
|
||||
// The freshly-pushed streaming turn is the tail, so it must NOT be the one evicted.
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'message.delta', payload: { text: 'in flight' } })
|
||||
expect(store.state.messages).toHaveLength(4)
|
||||
expect(store.state.messages[0]!.text).toBe('u1') // 'u0' dropped from the head, not the tail turn
|
||||
const live = store.state.messages.at(-1)!
|
||||
expect(live.role).toBe('assistant')
|
||||
expect(live.streaming).toBe(true)
|
||||
expect(live.parts?.[0]).toMatchObject({ type: 'text', text: 'in flight' })
|
||||
})
|
||||
|
||||
test('message.start is capped: opening a turn beyond the cap drops the oldest', () => {
|
||||
process.env[ENV_KEY] = '2'
|
||||
const store = createSessionStore()
|
||||
store.pushUser('a')
|
||||
store.pushUser('b')
|
||||
store.apply({ type: 'message.start' }) // array would be 3 → trimmed to 2
|
||||
expect(store.state.messages).toHaveLength(2)
|
||||
expect(store.state.messages[0]!.text).toBe('b') // 'a' dropped from the head
|
||||
expect(store.state.messages.at(-1)!.role).toBe('assistant')
|
||||
})
|
||||
|
||||
test('commitSnapshot caps an over-cap resume snapshot (oldest history dropped)', () => {
|
||||
process.env[ENV_KEY] = '3'
|
||||
const store = createSessionStore()
|
||||
const snapshot: Message[] = Array.from({ length: 8 }, (_, i) => ({ role: 'user', text: `h${i}` }))
|
||||
store.beginBuffer()
|
||||
store.commitSnapshot(snapshot)
|
||||
expect(store.state.messages).toHaveLength(3)
|
||||
expect(store.state.dropped).toBe(5) // 8 snapshot − 3 kept; resume SETS the count
|
||||
expect(store.state.messages[0]!.text).toBe('h5')
|
||||
expect(store.state.messages.at(-1)!.text).toBe('h7')
|
||||
})
|
||||
|
||||
test('defaults to 3000 when the env var is unset/invalid', () => {
|
||||
delete process.env[ENV_KEY]
|
||||
const store = createSessionStore()
|
||||
for (let i = 0; i < 3050; i++) store.pushUser(`m${i}`)
|
||||
expect(store.state.messages).toHaveLength(3000)
|
||||
expect(store.state.messages[0]!.text).toBe('m50') // oldest 50 dropped
|
||||
})
|
||||
|
||||
test('clearTranscript empties messages AND the applied dedup set', () => {
|
||||
const store = createSessionStore()
|
||||
store.pushUser('x')
|
||||
// seed the dedup set with an id, then confirm it is now treated as seen
|
||||
expect(store.duplicate('seen-1')).toBe(false)
|
||||
expect(store.duplicate('seen-1')).toBe(true)
|
||||
|
||||
store.clearTranscript()
|
||||
expect(store.state.messages).toHaveLength(0)
|
||||
// after clear the previously-seen id is processed again (the applied Set was cleared)
|
||||
expect(store.duplicate('seen-1')).toBe(false)
|
||||
})
|
||||
|
||||
test('clearTranscript resets the dropped counter (the truncation notice clears)', () => {
|
||||
process.env[ENV_KEY] = '2'
|
||||
const store = createSessionStore()
|
||||
for (let i = 0; i < 5; i++) store.pushUser(`m${i}`) // 5 pushed, cap 2 → 3 dropped
|
||||
expect(store.state.dropped).toBe(3)
|
||||
store.clearTranscript()
|
||||
expect(store.state.dropped).toBe(0)
|
||||
})
|
||||
})
|
||||
93
ui-opentui/src/test/toolOutput.test.ts
Normal file
93
ui-opentui/src/test/toolOutput.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* toolOutput unit test (spec v4 §5 Layer 4 — Hermes-specific contract). The
|
||||
* `{output,exit_code}` envelope unwrap + the line/char collapse, as pure data.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { collapseToolOutput, stripAnsi, stripOmittedNote, stripToolEnvelope, truncate } from '../logic/toolOutput.ts'
|
||||
|
||||
describe('stripAnsi (item 8 - gateway slash/notice text is ANSI-colored for Ink)', () => {
|
||||
const ESC = String.fromCharCode(27)
|
||||
test('removes SGR color codes, keeps the text', () => {
|
||||
expect(stripAnsi(`${ESC}[1;38;2;255;215;0m\u2713 Reasoning display: ON${ESC}[0m`)).toBe(
|
||||
'\u2713 Reasoning display: ON'
|
||||
)
|
||||
})
|
||||
test('removes italic + mouse sequences', () => {
|
||||
expect(stripAnsi(`${ESC}[2;3m Model thinking shown.${ESC}[0m`)).toBe(' Model thinking shown.')
|
||||
expect(stripAnsi(`hi${ESC}[<0;6;8mthere`)).toBe('hithere')
|
||||
})
|
||||
test('leaves plain text untouched', () => {
|
||||
expect(stripAnsi('just text')).toBe('just text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripOmittedNote (item 2 — peel the gateway verbose-tail label)', () => {
|
||||
test('extracts the lines/chars note and returns the clean body', () => {
|
||||
const { body, omittedNote } = stripOmittedNote(
|
||||
'[showing verbose tail; omitted 5 lines / 234 chars]\nline one\nline two'
|
||||
)
|
||||
expect(omittedNote).toBe('5 lines / 234 chars')
|
||||
expect(body).toBe('line one\nline two')
|
||||
})
|
||||
test('extracts a chars-only note', () => {
|
||||
const { body, omittedNote } = stripOmittedNote('[showing verbose tail; omitted 512 chars]\ntail body')
|
||||
expect(omittedNote).toBe('512 chars')
|
||||
expect(body).toBe('tail body')
|
||||
})
|
||||
test('passes through unlabeled output untouched', () => {
|
||||
const { body, omittedNote } = stripOmittedNote('normal output\nno prefix')
|
||||
expect(omittedNote).toBeUndefined()
|
||||
expect(body).toBe('normal output\nno prefix')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripToolEnvelope', () => {
|
||||
test('unwraps {output,exit_code} → output', () => {
|
||||
expect(stripToolEnvelope('{"output":"hi","exit_code":0}')).toBe('hi')
|
||||
})
|
||||
test('appends an [exit N] suffix on non-zero exit', () => {
|
||||
expect(stripToolEnvelope('{"output":"oops","exit_code":2}')).toBe('oops\n[exit 2]')
|
||||
})
|
||||
test('appends an [error] suffix when error is set', () => {
|
||||
expect(stripToolEnvelope('{"output":"x","error":"boom"}')).toBe('x\n[error] boom')
|
||||
})
|
||||
test('passes through non-JSON / non-envelope unchanged', () => {
|
||||
expect(stripToolEnvelope('just text')).toBe('just text')
|
||||
expect(stripToolEnvelope('{not json')).toBe('{not json')
|
||||
expect(stripToolEnvelope('{"result":"no output key"}')).toBe('{"result":"no output key"}')
|
||||
})
|
||||
test('unwraps a TAIL-capped envelope fragment (item 2 — gateway serialises then tail-caps)', () => {
|
||||
// head was cut, tail keeps the envelope close → strip the trailing close
|
||||
expect(stripToolEnvelope('zsh\nzutty", "exit_code": 0, "error": null}')).toBe('zsh\nzutty')
|
||||
// head survived, tail cut → strip the leading {"output": "
|
||||
expect(stripToolEnvelope('{"output": "line1\nline2')).toBe('line1\nline2')
|
||||
// real output that merely mentions exit_code is NOT mangled
|
||||
expect(stripToolEnvelope('the exit_code was 0 here')).toBe('the exit_code was 0 here')
|
||||
})
|
||||
test('un-double-escapes literal \\n when they dominate (item 7 verbose tail)', () => {
|
||||
// double-escaped output (literal backslash-n) → real newlines
|
||||
expect(stripToolEnvelope('a\\nb\\nc')).toBe('a\nb\nc')
|
||||
// genuine multi-line output (real newlines) with one literal \n is left alone
|
||||
expect(stripToolEnvelope('line1\nline2\nshow \\n here')).toBe('line1\nline2\nshow \\n here')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapseToolOutput / truncate', () => {
|
||||
test('caps to maxLines and reports the hidden count', () => {
|
||||
const c = collapseToolOutput('a\nb\nc\nd', 2, 10)
|
||||
expect(c.lines).toEqual(['a', 'b'])
|
||||
expect(c.hiddenLines).toBe(2)
|
||||
expect(c.truncated).toBe(true)
|
||||
})
|
||||
test('no truncation when within the cap', () => {
|
||||
const c = collapseToolOutput('a\nb', 5, 10)
|
||||
expect(c.lines).toEqual(['a', 'b'])
|
||||
expect(c.hiddenLines).toBe(0)
|
||||
expect(c.truncated).toBe(false)
|
||||
})
|
||||
test('truncate adds an ellipsis only when cut', () => {
|
||||
expect(truncate('abcdef', 4)).toBe('abc…')
|
||||
expect(truncate('ab', 4)).toBe('ab')
|
||||
})
|
||||
})
|
||||
143
ui-opentui/src/view/App.tsx
Normal file
143
ui-opentui/src/view/App.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* App — the Solid view shell (spec v4 §2 `view/App.tsx`). Header + a content zone
|
||||
* that is either the PAGER overlay (long slash output) or the normal
|
||||
* transcript + input zone; the input zone is one of: blocking prompt, session
|
||||
* switcher, generic picker (model/skills), or the composer. Fully themed (§7.5).
|
||||
*
|
||||
* header flexShrink:0 (top chrome line)
|
||||
* content flexGrow:1, minHeight:0 — Pager OR (transcript + input zone)
|
||||
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
|
||||
* input zone flexShrink:0 (PromptOverlay | SessionSwitcher | Picker | Composer)
|
||||
*
|
||||
* Overlays REPLACE rather than stack (a `<Switch>`), so the composer remounts +
|
||||
* refocuses when an overlay closes; the key that closed an overlay can't leak
|
||||
* into it because the close is deferred a tick.
|
||||
*/
|
||||
import { Match, Switch } from 'solid-js'
|
||||
|
||||
import { deferClose } from '../logic/defer.ts'
|
||||
import type { PromptHistory } from '../logic/history.ts'
|
||||
import type { PasteStore } from '../logic/pastes.ts'
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { Composer } from './composer.tsx'
|
||||
import { DimensionsProvider } from './dimensions.tsx'
|
||||
import { Header } from './header.tsx'
|
||||
import { AgentsDashboard } from './overlays/agentsDashboard.tsx'
|
||||
import { Pager } from './overlays/pager.tsx'
|
||||
import { Picker } from './overlays/picker.tsx'
|
||||
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||
import { StatusBar } from './statusBar.tsx'
|
||||
import { StatusLine } from './statusLine.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
import { Transcript } from './transcript.tsx'
|
||||
|
||||
export interface AppProps {
|
||||
readonly store: SessionStore
|
||||
readonly onSubmit?: (text: string) => void
|
||||
readonly onType?: (text: string) => void
|
||||
readonly onRespond?: (method: string, params: Record<string, unknown>) => void
|
||||
readonly onResume?: (sessionId: string) => void
|
||||
readonly sessionId?: () => string | undefined
|
||||
readonly history?: PromptHistory
|
||||
readonly onImagePaste?: () => void
|
||||
readonly pasteStore?: PasteStore
|
||||
}
|
||||
|
||||
const NOOP = () => {}
|
||||
const NOOP_RESPOND = () => {}
|
||||
const NOOP_RESUME = () => {}
|
||||
const NO_SESSION = () => undefined
|
||||
|
||||
export function App(props: AppProps) {
|
||||
const theme = useTheme()
|
||||
const blocked = () => props.store.state.prompt !== undefined
|
||||
const pager = () => props.store.state.pager
|
||||
const dashboard = () => props.store.state.dashboard
|
||||
const switcher = () => props.store.state.switcher
|
||||
const picker = () => props.store.state.picker
|
||||
// Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in
|
||||
// the freshly-remounted composer (see deferClose).
|
||||
const closePager = () => deferClose(() => props.store.closePager())
|
||||
const closeDashboard = () => deferClose(() => props.store.closeDashboard())
|
||||
const closeSwitcher = () => deferClose(() => props.store.closeSwitcher())
|
||||
const closePicker = () => deferClose(() => props.store.closePicker())
|
||||
const resume = (id: string) => {
|
||||
;(props.onResume ?? NOOP_RESUME)(id)
|
||||
closeSwitcher()
|
||||
}
|
||||
|
||||
return (
|
||||
<DimensionsProvider>
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
|
||||
{/* a bottom rule under the header bookends the transcript with the status
|
||||
bar's top rule — frames the chrome as intentional (item 8). */}
|
||||
<box border={['bottom']} borderColor={theme().color.border} style={{ flexShrink: 0 }}>
|
||||
<Header store={props.store} />
|
||||
</box>
|
||||
{/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */}
|
||||
<Switch
|
||||
fallback={
|
||||
<>
|
||||
<Transcript store={props.store} />
|
||||
{/* transient busy face floats at the bottom of the transcript area */}
|
||||
<StatusLine store={props.store} />
|
||||
{/* input region — a top-edge rule separates the status bar + textbox from the
|
||||
transcript above; the status bar sits directly ABOVE the composer (item 14). */}
|
||||
<box
|
||||
border={['top']}
|
||||
borderColor={theme().color.border}
|
||||
style={{ flexShrink: 0, flexDirection: 'column' }}
|
||||
>
|
||||
<StatusBar store={props.store} />
|
||||
<Switch
|
||||
fallback={
|
||||
<Composer
|
||||
onSubmit={props.onSubmit ?? NOOP}
|
||||
onType={props.onType}
|
||||
completions={() => props.store.state.completions ?? []}
|
||||
completionFrom={() => props.store.state.completionFrom}
|
||||
onDismiss={() => props.store.clearCompletions()}
|
||||
history={props.history}
|
||||
onImagePaste={props.onImagePaste}
|
||||
pasteStore={props.pasteStore}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match when={blocked()}>
|
||||
<PromptOverlay
|
||||
store={props.store}
|
||||
onRespond={props.onRespond ?? NOOP_RESPOND}
|
||||
sessionId={props.sessionId ?? NO_SESSION}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={switcher()}>
|
||||
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
|
||||
</Match>
|
||||
<Match when={picker()}>
|
||||
{p => (
|
||||
<Picker
|
||||
title={p().title}
|
||||
items={p().items}
|
||||
onPick={value => {
|
||||
p().onPick(value)
|
||||
closePicker()
|
||||
}}
|
||||
onClose={closePicker}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Match when={pager()}>{p => <Pager title={p().title} text={p().text} onClose={closePager} />}</Match>
|
||||
<Match when={dashboard()}>
|
||||
<AgentsDashboard subagents={props.store.state.subagents} onClose={closeDashboard} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</DimensionsProvider>
|
||||
)
|
||||
}
|
||||
240
ui-opentui/src/view/composer.tsx
Normal file
240
ui-opentui/src/view/composer.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Composer — the input row (spec v4 §2). A native <textarea> captured by ref;
|
||||
* Enter submits, the input clears imperatively, and a live slash-completion
|
||||
* dropdown renders ABOVE it as you type `/…` (spec §1 completions).
|
||||
*
|
||||
* Gotchas (§8 #3): `flexShrink:0` so it never collapses onto its rule; clear via
|
||||
* `.clear()` (NOT key-remount); a `submitting` re-entrancy guard.
|
||||
*
|
||||
* Completions: `onContentChange` reports the text → `onType` (entry boundary)
|
||||
* queries `complete.slash` and fills `completions()`. The textarea owns key input
|
||||
* (so live-refine-by-typing works), so we use Tab to accept the top match and Esc
|
||||
* to dismiss (arrow-nav would fight the textarea's cursor; a polish item).
|
||||
* `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here.
|
||||
*
|
||||
* Always-active input (item 2): the textarea focuses on mount, on click
|
||||
* (onMouseDown), and reclaims focus on the next PRINTABLE keystroke if focus ever
|
||||
* drifted off (e.g. the transcript scrollbox grabbed it on a mouse-scroll). Nav
|
||||
* keys are left alone so keyboard transcript-scroll still works (opencode keeps
|
||||
* the prompt focused via a reactive effect; here a keystroke net is enough since
|
||||
* the composer remounts+refocuses whenever an overlay closes).
|
||||
*/
|
||||
import { type PasteEvent, type TextareaRenderable } from '@opentui/core'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { For, onMount, Show } from 'solid-js'
|
||||
|
||||
import type { CompletionItem } from '../logic/store.ts'
|
||||
import type { PromptHistory } from '../logic/history.ts'
|
||||
import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts'
|
||||
import { useDimensions } from './dimensions.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
const GUTTER = 2
|
||||
|
||||
/** Keys that must NOT steal focus back to the composer (scroll/edit/nav). */
|
||||
const NAV_KEYS = new Set([
|
||||
'return',
|
||||
'linefeed',
|
||||
'tab',
|
||||
'escape',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'up',
|
||||
'down',
|
||||
'left',
|
||||
'right',
|
||||
'home',
|
||||
'end',
|
||||
'pageup',
|
||||
'pagedown',
|
||||
'clear',
|
||||
'menu'
|
||||
])
|
||||
|
||||
/** A printable, unmodified key press (recoverable into the textarea). */
|
||||
function isPrintableKey(k: {
|
||||
name: string
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
option: boolean
|
||||
super?: boolean
|
||||
sequence: string
|
||||
eventType?: string
|
||||
}): boolean {
|
||||
return (
|
||||
k.eventType !== 'release' &&
|
||||
!k.ctrl &&
|
||||
!k.meta &&
|
||||
!k.option &&
|
||||
!k.super &&
|
||||
!NAV_KEYS.has(k.name) &&
|
||||
typeof k.sequence === 'string' &&
|
||||
k.sequence.length >= 1 &&
|
||||
(k.sequence.codePointAt(0) ?? 0) >= 0x20
|
||||
)
|
||||
}
|
||||
|
||||
export function Composer(props: {
|
||||
onSubmit: (text: string) => void
|
||||
onType?: ((text: string) => void) | undefined
|
||||
completions?: (() => CompletionItem[]) | undefined
|
||||
completionFrom?: (() => number) | undefined
|
||||
onDismiss?: (() => void) | undefined
|
||||
history?: PromptHistory | undefined
|
||||
onImagePaste?: (() => void) | undefined
|
||||
pasteStore?: PasteStore | undefined
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const dims = useDimensions()
|
||||
// Auto-expand the input up to ~a third of the screen, then it scrolls internally
|
||||
// (opencode's prompt: minHeight 1, maxHeight max(6, ⌊rows/3⌋)).
|
||||
const maxHeight = () => Math.max(6, Math.floor(dims().height / 3))
|
||||
let ta: TextareaRenderable | undefined
|
||||
let submitting = false
|
||||
const completions = () => props.completions?.() ?? []
|
||||
|
||||
/** Replace the textarea content and park the cursor at the end (history recall). */
|
||||
const setBuffer = (text: string) => {
|
||||
if (!ta) return
|
||||
ta.setText(text)
|
||||
ta.cursorOffset = text.length
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (submitting || !ta) return
|
||||
// Expand any `[Pasted text #N]` placeholders back to their full content before
|
||||
// sending (item: pasted-text). No-op when nothing was placeheld.
|
||||
const text = (props.pasteStore?.expand(ta.plainText) ?? ta.plainText).trim()
|
||||
if (!text) return
|
||||
submitting = true
|
||||
props.onSubmit(text)
|
||||
props.history?.push(text)
|
||||
ta.clear()
|
||||
props.pasteStore?.clear()
|
||||
props.onDismiss?.()
|
||||
submitting = false
|
||||
}
|
||||
|
||||
useKeyboard(key => {
|
||||
// 1) completion accept (Tab) / dismiss (Esc) while the dropdown is open
|
||||
if (completions().length > 0) {
|
||||
if (key.name === 'tab') {
|
||||
const top = completions()[0]
|
||||
if (top && ta) {
|
||||
// splice only the token being completed (slash-arg / @-mention), not the
|
||||
// whole line — `completionFrom` is the gateway's replace_from / token start.
|
||||
const from = props.completionFrom?.() ?? 0
|
||||
const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length))
|
||||
setBuffer(before + top.text + ' ')
|
||||
props.onDismiss?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
props.onDismiss?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 2) prompt history (item 6): Up at the first line → older prompt; Down at the
|
||||
// last line → newer/draft. At the boundary the textarea's own up/down is a
|
||||
// no-op, so there's no conflict; mid-buffer it falls through to cursor moves.
|
||||
if (ta && props.history) {
|
||||
if (key.name === 'up' && ta.logicalCursor.row === 0) {
|
||||
const entry = props.history.prev(ta.plainText)
|
||||
if (entry !== null) setBuffer(entry)
|
||||
return
|
||||
}
|
||||
if (key.name === 'down' && ta.logicalCursor.row === ta.lineCount - 1) {
|
||||
const entry = props.history.next()
|
||||
if (entry !== null) setBuffer(entry)
|
||||
return
|
||||
}
|
||||
// any edit resets the recall cursor so the next Up starts from the bottom
|
||||
if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) {
|
||||
props.history.reset()
|
||||
}
|
||||
}
|
||||
// 3) always-active input (item 2): a printable key while the textarea lost
|
||||
// focus reclaims it. The renderer runs this GLOBAL handler BEFORE routing the
|
||||
// key to the focused renderable, so after focus() the SAME keystroke is still
|
||||
// delivered to the (now-focused) textarea — do NOT insert it here too, or the
|
||||
// first letter doubles. Nav/scroll keys are untouched.
|
||||
if (ta && !ta.focused && isPrintableKey(key)) {
|
||||
ta.focus()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => ta?.focus())
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||
<Show when={completions().length > 0}>
|
||||
<box
|
||||
style={{
|
||||
backgroundColor: theme().color.completionBg,
|
||||
flexDirection: 'column',
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1
|
||||
}}
|
||||
>
|
||||
{/* the completion dropdown is transient input chrome (menu rows + the
|
||||
key-hint) — not transcript content — so it's excluded from mouse
|
||||
selection (item 4). */}
|
||||
<For each={completions().slice(0, 8)}>
|
||||
{(c, i) => (
|
||||
<text selectable={false} fg={i() === 0 ? theme().color.accent : theme().color.text}>
|
||||
{c.display || c.text}
|
||||
{c.meta ? ` ${c.meta}` : ''}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<text selectable={false} fg={theme().color.muted}>
|
||||
Tab complete · Esc dismiss
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
{/* prompt glyph + textarea — the glyph (item 3) marks the input line so the
|
||||
composer is distinguished by structure (glyph + the status-bar rule above),
|
||||
not a background tint. */}
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
|
||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
|
||||
</text>
|
||||
</box>
|
||||
<textarea
|
||||
ref={el => (ta = el)}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
style={{ flexGrow: 1, minWidth: 0 }}
|
||||
placeholder={theme().brand.welcome}
|
||||
placeholderColor={theme().color.muted}
|
||||
textColor={theme().color.text}
|
||||
cursorColor={theme().color.accent}
|
||||
keyBindings={[{ action: 'submit', name: 'return' }]}
|
||||
onMouseDown={() => ta?.focus()}
|
||||
onSubmit={submit}
|
||||
onPaste={(e: PasteEvent) => {
|
||||
const text = new TextDecoder().decode(e.bytes)
|
||||
// An empty bracketed paste = an image-only clipboard (item 1) — read + attach it.
|
||||
if (text.trim() === '') {
|
||||
e.preventDefault()
|
||||
props.onImagePaste?.()
|
||||
return
|
||||
}
|
||||
// A large paste becomes a compact `[Pasted text #N +M lines]` chip instead
|
||||
// of flooding the input; the real text is expanded back on submit.
|
||||
if (props.pasteStore && shouldPlaceholder(text)) {
|
||||
e.preventDefault()
|
||||
ta?.insertText(props.pasteStore.add(text))
|
||||
return
|
||||
}
|
||||
// small pastes fall through to the textarea's native insert
|
||||
}}
|
||||
onContentChange={() => props.onType?.(ta?.plainText ?? '')}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
50
ui-opentui/src/view/dimensions.tsx
Normal file
50
ui-opentui/src/view/dimensions.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Shared, COALESCED terminal dimensions (item 4 — resize hardening). Raw
|
||||
* `useTerminalDimensions()` fires on every SIGWINCH tick; during a drag that's a
|
||||
* recompute/reflow storm across every width-sensitive component (tool bodies,
|
||||
* tables, status bar, banner). One provider runs the raw hook once and feeds a
|
||||
* single leading+trailing-debounced signal (opencode's createLeadingTrailingSignal
|
||||
* idiom, mirroring the gateway's 16ms event coalescing) that every consumer shares
|
||||
* — so they reflow together (no tearing) and at most once per COALESCE window.
|
||||
*/
|
||||
import { useTerminalDimensions } from '@opentui/solid'
|
||||
import { type Accessor, createContext, createEffect, createSignal, type JSX, onCleanup, useContext } from 'solid-js'
|
||||
|
||||
export interface Dims {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
const DimsContext = createContext<Accessor<Dims>>()
|
||||
const COALESCE_MS = 40
|
||||
|
||||
export function DimensionsProvider(props: { children: JSX.Element }) {
|
||||
const raw = useTerminalDimensions()
|
||||
const [dims, setDims] = createSignal<Dims>({ height: raw().height, width: raw().width })
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
createEffect(() => {
|
||||
const next: Dims = { height: raw().height, width: raw().width } // track raw
|
||||
const now = Date.now()
|
||||
if (now - last >= COALESCE_MS) {
|
||||
last = now
|
||||
setDims(next) // leading edge: respond immediately to the first change
|
||||
} else {
|
||||
// trailing edge: coalesce the burst, land on the final size once it settles
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
last = Date.now()
|
||||
setDims(next)
|
||||
}, COALESCE_MS)
|
||||
}
|
||||
})
|
||||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
return <DimsContext.Provider value={dims}>{props.children}</DimsContext.Provider>
|
||||
}
|
||||
|
||||
/** Coalesced dimensions; falls back to the raw hook outside a provider (e.g. headless tests). */
|
||||
export function useDimensions(): Accessor<Dims> {
|
||||
return useContext(DimsContext) ?? useTerminalDimensions()
|
||||
}
|
||||
30
ui-opentui/src/view/header.tsx
Normal file
30
ui-opentui/src/view/header.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Header — the top chrome line (spec v4 §2 `view/header.tsx`). Phase 2 skeleton:
|
||||
* brand · engine · ready/connecting, fully themed (`useTheme()`, NO hardcoded
|
||||
* styles — §7.5). Model / cwd / context% / cost land in Phase 5b once
|
||||
* `session.info` + `Usage` are wired.
|
||||
*/
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
export function Header(props: { store: SessionStore }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<box style={{ flexShrink: 0 }}>
|
||||
<text selectable={false}>
|
||||
{/* brand glyph in accent + name in primary/bold so the header reads as the
|
||||
top of the hierarchy, not just another text line (item 8). */}
|
||||
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
|
||||
<span style={{ fg: theme().color.primary }}>
|
||||
<b>{theme().brand.name}</b>
|
||||
</span>
|
||||
<span style={{ fg: theme().color.muted }}> · opentui · </span>
|
||||
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting…</span>}>
|
||||
<span style={{ fg: theme().color.ok }}>ready</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
203
ui-opentui/src/view/homeHint.tsx
Normal file
203
ui-opentui/src/view/homeHint.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* HomeHint — the empty-transcript home screen (items 12 + 9; Ink `branding.tsx`
|
||||
* parity). The HERMES-AGENT banner + a tagline, then a session info block
|
||||
* (model · Nous Research / dir / Session id), then SEPARATE collapsible sections —
|
||||
* Available Tools (enabled toolsets + their tools), Available Skills, MCP Servers —
|
||||
* and a summary line. Fully themed; decorative, so `selectable={false}` (item 4).
|
||||
*/
|
||||
import { createSignal, For, type JSX, Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { truncate } from '../logic/toolOutput.ts'
|
||||
import { useDimensions } from './dimensions.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
// The canonical HERMES-AGENT block logo (hermes_cli/banner.py), gold→amber→bronze.
|
||||
const BANNER: ReadonlyArray<readonly [string, 'primary' | 'accent' | 'border']> = [
|
||||
['██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', 'primary'],
|
||||
['██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', 'primary'],
|
||||
['███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║', 'accent'],
|
||||
['██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║', 'accent'],
|
||||
['██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║', 'border'],
|
||||
['╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝', 'border']
|
||||
]
|
||||
const BANNER_W = 102
|
||||
const TOOLSETS_MAX = 10
|
||||
|
||||
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`. */
|
||||
const shortModel = (m: string) => (m.includes('/') ? (m.split('/').at(-1) ?? m) : m)
|
||||
const HOME = process.env.HOME ?? ''
|
||||
const shortCwd = (cwd: string) => (HOME && cwd.startsWith(HOME) ? '~' + cwd.slice(HOME.length) : cwd)
|
||||
|
||||
export function HomeHint(props: { store: SessionStore }) {
|
||||
const theme = useTheme()
|
||||
const dims = useDimensions()
|
||||
const wide = () => dims().width >= BANNER_W
|
||||
const cat = () => props.store.state.catalog
|
||||
const info = () => props.store.state.info
|
||||
const enabledToolsets = () => (cat()?.tools.toolsets ?? []).filter(t => t.enabled)
|
||||
|
||||
// A collapsible section: ▸/▾ accent chevron + label title + optional muted suffix.
|
||||
function Section(p: { title: string; suffix?: string; open?: boolean; children: JSX.Element }) {
|
||||
const [open, setOpen] = createSignal(p.open ?? false)
|
||||
return (
|
||||
<box style={{ flexDirection: 'column', marginTop: 1 }}>
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => setOpen(o => !o)}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.accent }}>{open() ? '▾ ' : '▸ '}</span>
|
||||
<span style={{ fg: theme().color.label }}>{p.title}</span>
|
||||
<Show when={p.suffix}>
|
||||
<span style={{ fg: theme().color.muted }}>{` ${p.suffix}`}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={open()}>
|
||||
<box
|
||||
style={{ flexDirection: 'column', marginLeft: 2, paddingLeft: 1 }}
|
||||
border={['left']}
|
||||
borderColor={theme().color.border}
|
||||
>
|
||||
{p.children}
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, marginTop: 1 }}>
|
||||
{/* banner — full block logo when there's room, else a compact brand line */}
|
||||
<Show
|
||||
when={wide()}
|
||||
fallback={
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.accent }}>{theme().brand.icon} </span>
|
||||
<span style={{ fg: theme().color.primary }}>
|
||||
<b>{theme().brand.name}</b>
|
||||
</span>
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<For each={BANNER}>
|
||||
{([line, tone]) => (
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color[tone] }}>{line}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
|
||||
<span style={{ fg: theme().color.muted }}>Nous Research · Messenger of the Digital Gods</span>
|
||||
</text>
|
||||
|
||||
{/* framed session panel (Ink SessionPanel parity) — the bordered box is the
|
||||
key "this is a designed home screen, not log output" signal. */}
|
||||
<box
|
||||
style={{ flexDirection: 'column', marginTop: 1, paddingLeft: 1, paddingRight: 1 }}
|
||||
border
|
||||
borderColor={theme().color.border}
|
||||
>
|
||||
{/* session info block: model · Nous Research / dir / Session id */}
|
||||
<box style={{ flexDirection: 'column' }}>
|
||||
<Show when={info().model}>
|
||||
{model => (
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.accent }}>{shortModel(model())}</span>
|
||||
<span style={{ fg: theme().color.muted }}> · Nous Research</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={info().cwd}>
|
||||
{cwd => (
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{shortCwd(cwd())}</span>
|
||||
<Show when={info().branch}>
|
||||
<span style={{ fg: theme().color.muted }}>{` (${info().branch})`}</span>
|
||||
</Show>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.store.state.sessionId}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>Session: </span>
|
||||
<span style={{ fg: theme().color.border }}>{props.store.state.sessionId}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* SEPARATE collapsible sections (Ink parity) + summary */}
|
||||
<Show when={cat()}>
|
||||
{c => (
|
||||
<box style={{ flexDirection: 'column' }}>
|
||||
<Section title="Available Tools" open>
|
||||
<For each={enabledToolsets().slice(0, TOOLSETS_MAX)}>
|
||||
{ts => (
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.label }}>{`${ts.name}: `}</span>
|
||||
<span style={{ fg: theme().color.muted }}>
|
||||
{truncate(
|
||||
ts.tools.join(', ') || `${ts.count} tools`,
|
||||
Math.max(20, dims().width - ts.name.length - 8)
|
||||
)}
|
||||
</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<Show when={enabledToolsets().length > TOOLSETS_MAX}>
|
||||
<text selectable={false}>
|
||||
<span
|
||||
style={{ fg: theme().color.muted }}
|
||||
>{`(and ${enabledToolsets().length - TOOLSETS_MAX} more toolsets…)`}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={`Available Skills (${c().skills.total})`}
|
||||
suffix={`in ${c().skills.categories.length} categories`}
|
||||
>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>
|
||||
{c()
|
||||
.skills.categories.map(s => `${s.name} (${s.count})`)
|
||||
.join(' ')}
|
||||
</span>
|
||||
</text>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={`MCP Servers (${c().mcp.servers.length})`}
|
||||
suffix={c().mcp.servers.length ? 'connected' : ''}
|
||||
>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{c().mcp.servers.join(' ') || 'none configured'}</span>
|
||||
</text>
|
||||
</Section>
|
||||
|
||||
<box style={{ marginTop: 1 }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.text }}>{`${c().tools.total} tools`}</span>
|
||||
<span
|
||||
style={{ fg: theme().color.muted }}
|
||||
>{` · ${c().skills.total} skills · ${c().mcp.servers.length} MCP · `}</span>
|
||||
<span style={{ fg: theme().color.accent }}>/help</span>
|
||||
<span style={{ fg: theme().color.muted }}> for commands</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
{/* end framed session panel */}
|
||||
|
||||
<box style={{ marginTop: 1 }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>
|
||||
Type to chat · ↑↓ history · @file to mention · Ctrl+C to stop/quit
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
34
ui-opentui/src/view/keymap.tsx
Normal file
34
ui-opentui/src/view/keymap.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* keymap.tsx — thin Solid helpers over the native `@opentui/keymap` (Phase 3).
|
||||
*
|
||||
* `useCloseLayer` is the shared CLOSE binding for overlays/prompts: a `close`
|
||||
* command bound to Esc and Ctrl+C, scoped to the overlay's root box via a
|
||||
* `focus-within` layer (the default when a `target` accessor is present). The
|
||||
* box itself isn't focused — the native `<select>`/`<textarea>` inside it is —
|
||||
* so `focus-within` is what makes the layer active while the overlay owns the
|
||||
* screen. The keymap host is provided once at the entry by `<KeymapProvider>`.
|
||||
*/
|
||||
import type { BoxRenderable } from '@opentui/core'
|
||||
import { useBindings } from '@opentui/keymap/solid'
|
||||
|
||||
/**
|
||||
* Bind Esc / Ctrl+C → `onClose`, scoped to the given root box (focus-within).
|
||||
* Until the ref resolves the layer simply isn't registered (useBindings waits).
|
||||
*/
|
||||
export function useCloseLayer(target: () => BoxRenderable | undefined, onClose: () => void): void {
|
||||
useBindings<BoxRenderable>(() => ({
|
||||
target,
|
||||
commands: [
|
||||
{
|
||||
name: 'close',
|
||||
run() {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
],
|
||||
bindings: [
|
||||
{ key: 'escape', cmd: 'close' },
|
||||
{ key: { name: 'c', ctrl: true }, cmd: 'close' }
|
||||
]
|
||||
}))
|
||||
}
|
||||
79
ui-opentui/src/view/markdown.tsx
Normal file
79
ui-opentui/src/view/markdown.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Markdown — assistant/reasoning text via the NATIVE `<markdown>` renderable
|
||||
* (`MarkdownRenderable`), exactly as opencode's TextPart (`routes/session/index.tsx`
|
||||
* :1687 `<markdown streaming internalBlockMode="top-level" tableOptions conceal>`).
|
||||
*
|
||||
* Why `<markdown>` (not `<code filetype="markdown">`): the anti-flicker mechanism
|
||||
* is `internalBlockMode="top-level"` — each top-level block (heading/para/list/
|
||||
* table/fence) becomes its own child renderable and `_stableBlockCount` (managed
|
||||
* internally) reports the settled head prefix, so stable blocks are NOT re-rendered
|
||||
* per streamed delta. The old `<code>` path re-measured the whole buffer each delta
|
||||
* → the content height oscillated → the scrollbar grew/shrank (the streaming
|
||||
* flicker regression). `tableOptions` renders GFM tables as an aligned grid WITH
|
||||
* inline markdown (bold/italic/code) inside cells — so a separate table renderer
|
||||
* is unnecessary. `streaming` keeps the trailing block open while chunks append and
|
||||
* finalizes it (half-open tables/fences) when flipped false.
|
||||
*
|
||||
* The `SyntaxStyle` is derived from the active theme (no hardcoded styles — §7.5)
|
||||
* and cached by theme-object identity, so all text parts share ONE instance and
|
||||
* it's rebuilt only when the skin changes (a new `Theme` object).
|
||||
*/
|
||||
import { RGBA, SyntaxStyle } from '@opentui/core'
|
||||
|
||||
import type { Theme } from '../logic/theme.ts'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
const FALLBACK = RGBA.fromHex('#E6EDF3')
|
||||
const HEX6 = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
/** Theme colors are usually hex but may be `ansi256(n)`/`rgb(...)` after light-mode
|
||||
* normalization — only hand hex to RGBA.fromHex, else fall back. */
|
||||
function rgba(color: string): RGBA {
|
||||
return HEX6.test(color) ? RGBA.fromHex(color) : FALLBACK
|
||||
}
|
||||
|
||||
function buildSyntaxStyle(theme: Theme): SyntaxStyle {
|
||||
const c = theme.color
|
||||
return SyntaxStyle.fromStyles({
|
||||
default: { fg: rgba(c.text) },
|
||||
'markup.heading': { bold: true, fg: rgba(c.primary) },
|
||||
'markup.heading.1': { bold: true, fg: rgba(c.primary) },
|
||||
'markup.heading.2': { bold: true, fg: rgba(c.accent) },
|
||||
'markup.heading.3': { bold: true, fg: rgba(c.accent) },
|
||||
'markup.bold': { bold: true, fg: rgba(c.text) },
|
||||
'markup.italic': { fg: rgba(c.text), italic: true },
|
||||
'markup.list': { fg: rgba(c.accent) },
|
||||
'markup.quote': { fg: rgba(c.muted) },
|
||||
'markup.link': { fg: rgba(c.accent) },
|
||||
'markup.raw': { fg: rgba(c.label) },
|
||||
'markup.raw.block': { fg: rgba(c.label) }
|
||||
})
|
||||
}
|
||||
|
||||
let cache: { theme: Theme; style: SyntaxStyle } | undefined
|
||||
function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
||||
if (cache && cache.theme === theme) return cache.style
|
||||
const style = buildSyntaxStyle(theme)
|
||||
cache = { style, theme }
|
||||
return style
|
||||
}
|
||||
|
||||
export function Markdown(props: { text: string; streaming?: boolean; fg?: string }) {
|
||||
const theme = useTheme()
|
||||
// `internalBlockMode="top-level"` is the anti-flicker mode (stable head blocks
|
||||
// aren't re-rendered per delta); `tableOptions` gives native GFM tables with
|
||||
// inline formatting; `fg` overrides the base text color (muted for reasoning).
|
||||
// `conceal` hides the markdown markers for clean prose — mouse-selection then
|
||||
// copies the RENDERED text (markers gone) via native selection, by design.
|
||||
return (
|
||||
<markdown
|
||||
content={props.text}
|
||||
syntaxStyle={syntaxStyleFor(theme())}
|
||||
streaming={props.streaming ?? false}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: 'grid', borderColor: theme().color.border }}
|
||||
conceal
|
||||
fg={props.fg ?? theme().color.text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
95
ui-opentui/src/view/messageLine.tsx
Normal file
95
ui-opentui/src/view/messageLine.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* MessageLine — renders one transcript row (spec v4 §2 / §7). An assistant turn
|
||||
* is ONE ordered `parts[]` dispatched by `<Switch>`/`<Match>` on `part.type`, so
|
||||
* text / reasoning / tool interleave INLINE (the §7 fix for "tools dump below").
|
||||
* User/system rows (and settled/resumed assistant rows with no parts) render flat
|
||||
* `text`. Fully themed; rich text via <b>/<span>, never an attributes bitmask (§8 #1).
|
||||
*
|
||||
* Stable `id` per part as the <For> key so a new tool part below a streaming text
|
||||
* part doesn't remount it. Native <markdown> for text parts lands in 2b-ii.
|
||||
*/
|
||||
import { For, Match, Show, Switch } from 'solid-js'
|
||||
|
||||
import type { Message } from '../logic/store.ts'
|
||||
import { Markdown } from './markdown.tsx'
|
||||
import { ReasoningPart } from './reasoningPart.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
import { ToolPart } from './toolPart.tsx'
|
||||
|
||||
const GUTTER = 2
|
||||
|
||||
export function MessageLine(props: { message: Message }) {
|
||||
const theme = useTheme()
|
||||
const m = () => props.message
|
||||
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
|
||||
// Role-distinct color IS the hierarchy (Ink model): the human's turn is tinted
|
||||
// GOLD (label), the agent's answer is BRIGHT (text), system notes are DIM (muted).
|
||||
const glyphFg = () =>
|
||||
m().role === 'user' ? theme().color.label : m().role === 'assistant' ? theme().color.accent : theme().color.muted
|
||||
const bodyFg = () =>
|
||||
m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text
|
||||
const hasParts = () => (m().parts?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
// One blank line above every turn so user / assistant / tool blocks read as
|
||||
// distinct turns (item: spacing). The gold-vs-bright color split does the rest.
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}>
|
||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||
{/* the role glyph is decorative — exclude it from mouse selection (item 4).
|
||||
Bold so the user `❯` / assistant `⚕` turn boundaries pop (item 8). */}
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: glyphFg() }}>
|
||||
<b>{glyph()}</b>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
|
||||
reasoning / tool regardless of order or stream timing, so blank lines
|
||||
don't pop in and out as parts are created/merged mid-stream. */}
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: 1 }}>
|
||||
<Show
|
||||
when={m().role === 'assistant' && hasParts()}
|
||||
fallback={
|
||||
// No parts yet: the just-started streaming turn shows ONLY the caret,
|
||||
// inline with the glyph (not an empty line + a dangling caret below —
|
||||
// item 10 cursor misalignment); a settled row shows its flat text.
|
||||
<Show
|
||||
when={m().streaming && !hasParts()}
|
||||
fallback={
|
||||
// themed selection: a solid muted/accent bar that preserves the
|
||||
// text fg (no selectionFg → the original color shows through, so a
|
||||
// highlight over content reads as a clean bar, not SGR-inverse).
|
||||
<text selectionBg={theme().color.selectionBg}>
|
||||
<span style={{ fg: bodyFg() }}>{m().text}</span>
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text selectable={false}>
|
||||
{/* streaming caret — a cursor glyph, not content (item 4) */}
|
||||
<span style={{ fg: theme().color.muted }}>▍</span>
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For each={m().parts ?? []}>
|
||||
{part => (
|
||||
<Switch>
|
||||
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
|
||||
<Match when={part.type === 'reasoning' && part}>
|
||||
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
|
||||
</Match>
|
||||
<Match when={part.type === 'text' && part}>
|
||||
{/* ONE stable native <markdown> fed the growing text in place (no
|
||||
per-delta remount → no scrollbar flicker, #2); it renders GFM
|
||||
tables natively (#3). Leading/trailing blanks stripped so the
|
||||
column `gap` is the sole inter-part spacing (item 5). */}
|
||||
{t => <Markdown text={t().text.replace(/^\n+|\n+$/g, '')} streaming={m().streaming ?? false} />}
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
143
ui-opentui/src/view/overlays/agentsDashboard.tsx
Normal file
143
ui-opentui/src/view/overlays/agentsDashboard.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* AgentsDashboard — the delegation/subagents view (spec §2b; Ink `agentsOverlay`,
|
||||
* item 15 "look into an agent trace live"). Master-detail:
|
||||
* - top: the subagents tracked from the `subagent.*` stream, indented by depth;
|
||||
* ↑/↓ SELECT a row (highlighted).
|
||||
* - bottom: the SELECTED subagent's live trace (goal · status · model, latest
|
||||
* thought, and the tool/progress/summary log) — sticky-bottom so it follows
|
||||
* live; PgUp/PgDn scroll it.
|
||||
* Esc/Ctrl+C close (native keymap). §8 #2 scrollbox gotchas (minHeight:0, sticky bottom).
|
||||
*/
|
||||
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { createSignal, For, onMount, Show } from 'solid-js'
|
||||
|
||||
import type { SubagentInfo } from '../../logic/store.ts'
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
const PAGE = 8
|
||||
|
||||
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
|
||||
const c = theme().color
|
||||
if (status === 'complete') return c.ok
|
||||
if (status === 'tool' || status === 'working') return c.accent
|
||||
if (status.includes('error') || status === 'failed') return c.error
|
||||
return c.warn
|
||||
}
|
||||
|
||||
export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) {
|
||||
const theme = useTheme()
|
||||
const [sel, setSel] = createSignal(0)
|
||||
let rootRef: BoxRenderable | undefined
|
||||
let traceBox: ScrollBoxRenderable | undefined
|
||||
|
||||
const count = () => props.subagents.length
|
||||
const selected = () => Math.min(sel(), Math.max(0, count() - 1))
|
||||
const current = () => props.subagents[selected()]
|
||||
|
||||
// Close (Esc/Ctrl+C) is the native keymap; select + scroll stay in the raw global
|
||||
// handler below. Focus the root box on mount so the focus-within close layer is active.
|
||||
onMount(() => rootRef?.focus())
|
||||
useCloseLayer(
|
||||
() => rootRef,
|
||||
() => props.onClose()
|
||||
)
|
||||
|
||||
useKeyboard(key => {
|
||||
// `q` closes (footer advertises "Esc/q close"); Esc/Ctrl+C close via the keymap.
|
||||
if (key.name === 'q') return props.onClose()
|
||||
if (key.name === 'up') setSel(s => Math.max(0, s - 1))
|
||||
else if (key.name === 'down') setSel(s => Math.min(Math.max(0, count() - 1), s + 1))
|
||||
else if (key.name === 'pageup') traceBox?.scrollBy(-PAGE)
|
||||
else if (key.name === 'pagedown') traceBox?.scrollBy(PAGE)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={el => (rootRef = el)}
|
||||
focusable
|
||||
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
|
||||
border
|
||||
>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>
|
||||
⛓ Agents · {count()} subagent{count() === 1 ? '' : 's'}
|
||||
</b>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* master: the subagent list (↑/↓ select) */}
|
||||
<box style={{ flexShrink: 0, flexDirection: 'column', maxHeight: 10 }}>
|
||||
<Show
|
||||
when={count() > 0}
|
||||
fallback={<text fg={theme().color.muted}>No subagents yet — delegate a task to spawn one.</text>}
|
||||
>
|
||||
<For each={props.subagents}>
|
||||
{(sa, i) => (
|
||||
<text onMouseDown={() => setSel(i())}>
|
||||
<span style={{ fg: theme().color.muted }}>{' '.repeat(Math.max(0, sa.depth))}</span>
|
||||
<span style={{ fg: i() === selected() ? theme().color.accent : theme().color.muted }}>
|
||||
{i() === selected() ? '▸ ' : ' '}
|
||||
</span>
|
||||
<span style={{ fg: statusColor(sa.status, theme) }}>{`● ${sa.status}`}</span>
|
||||
<span style={{ fg: theme().color.label }}>{` ${sa.goal || sa.id}`}</span>
|
||||
<span style={{ fg: theme().color.muted }}>{sa.lastTool ? ` ⚡${sa.lastTool}` : ''}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* detail: the selected subagent's live trace */}
|
||||
<box style={{ flexGrow: 1, minHeight: 0, flexDirection: 'column', borderColor: theme().color.border }} border>
|
||||
<Show when={current()} fallback={<text fg={theme().color.muted}> </text>}>
|
||||
{sa => (
|
||||
<>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text>
|
||||
<span style={{ fg: theme().color.label }}>{sa().goal || sa().id}</span>
|
||||
<span style={{ fg: statusColor(sa().status, theme) }}>{` · ${sa().status}`}</span>
|
||||
<span style={{ fg: theme().color.muted }}>{sa().model ? ` · ${sa().model}` : ''}</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={sa().thought}>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text>
|
||||
<span style={{ fg: theme().color.muted }}>{`🧠 ${sa().thought}`}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box style={{ flexGrow: 1, minHeight: 0, paddingLeft: 1 }}>
|
||||
<scrollbox
|
||||
ref={el => (traceBox = el)}
|
||||
style={{ flexGrow: 1, minHeight: 0 }}
|
||||
stickyScroll
|
||||
stickyStart="bottom"
|
||||
>
|
||||
<Show
|
||||
when={(sa().trace?.length ?? 0) > 0}
|
||||
fallback={<text fg={theme().color.muted}>(no activity yet)</text>}
|
||||
>
|
||||
<For each={sa().trace ?? []}>
|
||||
{line => (
|
||||
<text>
|
||||
<span style={{ fg: theme().color.muted }}>{line}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.muted}>Esc/q close · ↑↓ select · PgUp/PgDn scroll trace</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
70
ui-opentui/src/view/overlays/pager.tsx
Normal file
70
ui-opentui/src/view/overlays/pager.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Pager — a full-height scrollable text viewer (spec §2b `FloatBox` pager).
|
||||
* Porting it unlocks the long-output slash commands (/status /logs /history
|
||||
* /tools) at once. Replaces the transcript+composer while open (the App swaps it
|
||||
* in on `store.state.pager`).
|
||||
*
|
||||
* Scrolling is driven explicitly via a GLOBAL `useKeyboard` → `scrollBy`/`scrollTo`
|
||||
* (no reliance on focus); Esc/Ctrl+C close via the native keymap. Carries the §8 #2
|
||||
* scrollbox gotchas (minHeight:0 wrapper+box, NO flexDirection on the box root).
|
||||
*/
|
||||
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { For, onMount } from 'solid-js'
|
||||
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
const PAGE = 10
|
||||
|
||||
export function Pager(props: { title: string; text: string; onClose: () => void }) {
|
||||
const theme = useTheme()
|
||||
let rootRef: BoxRenderable | undefined
|
||||
let box: ScrollBoxRenderable | undefined
|
||||
const lines = () => props.text.split('\n')
|
||||
|
||||
// Close (Esc/Ctrl+C) is the native keymap; scroll keys stay in the raw global
|
||||
// handler below. Focus the root box on mount so the focus-within close layer is
|
||||
// active (the scrollbox isn't focused — scroll is global, not focus-gated).
|
||||
onMount(() => rootRef?.focus())
|
||||
useCloseLayer(
|
||||
() => rootRef,
|
||||
() => props.onClose()
|
||||
)
|
||||
|
||||
useKeyboard(key => {
|
||||
// `q` closes (the footer advertises "Esc/q close"); Esc/Ctrl+C close via the
|
||||
// keymap layer above. Scroll stays raw (not focus-gated).
|
||||
if (key.name === 'q') return props.onClose()
|
||||
if (!box) return
|
||||
if (key.name === 'up') box.scrollBy(-1)
|
||||
else if (key.name === 'down') box.scrollBy(1)
|
||||
else if (key.name === 'pageup') box.scrollBy(-PAGE)
|
||||
else if (key.name === 'pagedown') box.scrollBy(PAGE)
|
||||
else if (key.name === 'home') box.scrollTo(0)
|
||||
else if (key.name === 'end') box.scrollTo({ x: 0, y: box.scrollHeight })
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={el => (rootRef = el)}
|
||||
focusable
|
||||
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
|
||||
border
|
||||
>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>{props.title}</b>
|
||||
</text>
|
||||
</box>
|
||||
<box style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<scrollbox ref={el => (box = el)} style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<For each={lines()}>{line => <text fg={theme().color.text}>{line}</text>}</For>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.muted}>Esc/q close · ↑↓/PgUp/PgDn/Home/End scroll</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
57
ui-opentui/src/view/overlays/picker.tsx
Normal file
57
ui-opentui/src/view/overlays/picker.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Picker — a generic titled `<select>` overlay (spec §2b). Powers the model
|
||||
* picker (/model) and skills hub (/skills); the chosen value runs `onPick`.
|
||||
* Native select nav (↑↓/j/k/Enter); a small useKeyboard adds Esc/Ctrl+C close.
|
||||
* Replaces the composer while open.
|
||||
*/
|
||||
import type { BoxRenderable } from '@opentui/core'
|
||||
import { createMemo } from 'solid-js'
|
||||
|
||||
import type { PickerItem } from '../../logic/store.ts'
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
export function Picker(props: {
|
||||
title: string
|
||||
items: PickerItem[]
|
||||
onPick: (value: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
let rootRef: BoxRenderable | undefined
|
||||
// Native select handles ↑↓/j/k/Enter; the keymap owns Esc/Ctrl+C close.
|
||||
useCloseLayer(
|
||||
() => rootRef,
|
||||
() => props.onClose()
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value }))
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={el => (rootRef = el)}
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>{props.title}</b>
|
||||
</text>
|
||||
<select
|
||||
focused
|
||||
options={options()}
|
||||
onSelect={(_index, option) => {
|
||||
if (option) props.onPick(String(option.value))
|
||||
}}
|
||||
backgroundColor={theme().color.statusBg}
|
||||
selectedBackgroundColor={theme().color.selectionBg}
|
||||
textColor={theme().color.text}
|
||||
selectedTextColor={theme().color.text}
|
||||
descriptionColor={theme().color.muted}
|
||||
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
|
||||
/>
|
||||
<text fg={theme().color.muted}>↑↓ select · Enter choose · Esc cancel</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
60
ui-opentui/src/view/overlays/sessionSwitcher.tsx
Normal file
60
ui-opentui/src/view/overlays/sessionSwitcher.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* SessionSwitcher — pick a session to resume (spec §2b; Ink
|
||||
* `activeSessionSwitcher.tsx`). A native `<select>` over `session.list` rows;
|
||||
* Enter resumes the chosen session (the entry runs the same resume-hydrate path
|
||||
* as launch), Esc/Ctrl+C closes. Replaces the composer while open.
|
||||
*/
|
||||
import type { BoxRenderable } from '@opentui/core'
|
||||
import { createMemo } from 'solid-js'
|
||||
|
||||
import type { SessionItem } from '../../logic/store.ts'
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
export function SessionSwitcher(props: {
|
||||
sessions: SessionItem[]
|
||||
onPick: (sessionId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
let rootRef: BoxRenderable | undefined
|
||||
// Native select handles ↑↓/Enter; the keymap owns Esc/Ctrl+C close.
|
||||
useCloseLayer(
|
||||
() => rootRef,
|
||||
() => props.onClose()
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
props.sessions.map(s => ({
|
||||
description: `${s.messageCount} msgs${s.preview ? ` · ${s.preview.slice(0, 60)}` : ''}`,
|
||||
name: s.title || s.preview.slice(0, 48) || s.id,
|
||||
value: s.id
|
||||
}))
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={el => (rootRef = el)}
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>⟲ Resume a session</b>
|
||||
</text>
|
||||
<select
|
||||
focused
|
||||
options={options()}
|
||||
onSelect={(_index, option) => {
|
||||
if (option) props.onPick(String(option.value))
|
||||
}}
|
||||
backgroundColor={theme().color.statusBg}
|
||||
selectedBackgroundColor={theme().color.selectionBg}
|
||||
textColor={theme().color.text}
|
||||
selectedTextColor={theme().color.text}
|
||||
descriptionColor={theme().color.muted}
|
||||
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
|
||||
/>
|
||||
<text fg={theme().color.muted}>↑↓ select · Enter resume · Esc cancel</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
61
ui-opentui/src/view/prompts/approvalPrompt.tsx
Normal file
61
ui-opentui/src/view/prompts/approvalPrompt.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* ApprovalPrompt — dangerous-command approval (spec §8 #6). Native `<select>`
|
||||
* (built-in ↑↓/j/k/Enter nav) over once/session/always/deny; a small `useKeyboard`
|
||||
* adds the Esc/Ctrl+C → deny cancel path the select doesn't cover. Answered via
|
||||
* `approval.respond {choice, session_id}`.
|
||||
*/
|
||||
import type { BoxRenderable } from '@opentui/core'
|
||||
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
const OPTIONS = [
|
||||
{ description: 'Run this command this one time', name: 'Approve once', value: 'once' },
|
||||
{ description: 'Allow for the rest of this session', name: 'Approve for session', value: 'session' },
|
||||
{ description: 'Always allow this command', name: 'Always approve', value: 'always' },
|
||||
{ description: 'Reject this command', name: 'Deny', value: 'deny' }
|
||||
]
|
||||
|
||||
export function ApprovalPrompt(props: {
|
||||
command: string
|
||||
description: string
|
||||
onChoose: (choice: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
let rootRef: BoxRenderable | undefined
|
||||
// Native select handles ↑↓/j/k/Enter over the options; the keymap owns the
|
||||
// Esc/Ctrl+C → deny cancel path the select doesn't cover.
|
||||
useCloseLayer(
|
||||
() => rootRef,
|
||||
() => props.onCancel()
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={el => (rootRef = el)}
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.warn}>
|
||||
<b>⚠ Approval required</b>
|
||||
</text>
|
||||
<text fg={theme().color.text}>{props.command}</text>
|
||||
{props.description ? <text fg={theme().color.muted}>{props.description}</text> : null}
|
||||
<select
|
||||
focused
|
||||
options={OPTIONS}
|
||||
onSelect={(_index, option) => {
|
||||
if (option) props.onChoose(String(option.value))
|
||||
}}
|
||||
backgroundColor={theme().color.statusBg}
|
||||
selectedBackgroundColor={theme().color.selectionBg}
|
||||
textColor={theme().color.text}
|
||||
selectedTextColor={theme().color.text}
|
||||
descriptionColor={theme().color.muted}
|
||||
style={{ height: 8, marginTop: 1 }}
|
||||
/>
|
||||
<text fg={theme().color.muted}>↑↓ select · Enter confirm · Esc/Ctrl+C deny</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
BIN
ui-opentui/src/view/prompts/clarifyPrompt.tsx
Normal file
BIN
ui-opentui/src/view/prompts/clarifyPrompt.tsx
Normal file
Binary file not shown.
58
ui-opentui/src/view/prompts/confirmPrompt.tsx
Normal file
58
ui-opentui/src/view/prompts/confirmPrompt.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* ConfirmPrompt — a LOCAL (non-gateway) Y/N dialog (spec §2a). Driven by a local
|
||||
* callback, not an RPC: y/Enter → confirm, n/Esc/Ctrl+C → cancel. Used by client
|
||||
* slash commands like /clear and /new.
|
||||
*/
|
||||
import type { BoxRenderable } from '@opentui/core'
|
||||
import { useBindings } from '@opentui/keymap/solid'
|
||||
import { onMount } from 'solid-js'
|
||||
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
export function ConfirmPrompt(props: { message: string; onYes: () => void; onNo: () => void }) {
|
||||
const theme = useTheme()
|
||||
let rootRef: BoxRenderable | undefined
|
||||
// No focusable child here (unlike the <select> prompts), so focus the dialog box
|
||||
// itself on mount — that makes the focus-within keymap layer below active.
|
||||
onMount(() => rootRef?.focus())
|
||||
// Local Y/N dialog: y/Enter → confirm, n/Esc/Ctrl+C → cancel, scoped to the
|
||||
// dialog box (focus-within) via the native keymap.
|
||||
useBindings<BoxRenderable>(() => ({
|
||||
target: () => rootRef,
|
||||
commands: [
|
||||
{
|
||||
name: 'confirm',
|
||||
run() {
|
||||
props.onYes()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cancel',
|
||||
run() {
|
||||
props.onNo()
|
||||
}
|
||||
}
|
||||
],
|
||||
bindings: [
|
||||
{ key: 'y', cmd: 'confirm' },
|
||||
{ key: 'return', cmd: 'confirm' },
|
||||
{ key: 'n', cmd: 'cancel' },
|
||||
{ key: 'escape', cmd: 'cancel' },
|
||||
{ key: { name: 'c', ctrl: true }, cmd: 'cancel' }
|
||||
]
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={el => (rootRef = el)}
|
||||
focusable
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.warn}>
|
||||
<b>{props.message}</b>
|
||||
</text>
|
||||
<text fg={theme().color.muted}>y/Enter confirm · n/Esc cancel</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
63
ui-opentui/src/view/prompts/maskedPrompt.tsx
Normal file
63
ui-opentui/src/view/prompts/maskedPrompt.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* MaskedPrompt — sudo (🔐) / secret (🔑) masked entry (spec §8 #6). OpenTUI's
|
||||
* `<input>` has NO native mask (only value/placeholder/maxLength), and feeding it
|
||||
* stars via `value` is a feedback loop (onInput reports the masked value), so we
|
||||
* own a hidden buffer and capture raw keystrokes via `useKeyboard`, rendering '*'
|
||||
* per char — the robust path for masked input (verified in the React build).
|
||||
*
|
||||
* Enter submits the real buffer; Esc/Ctrl+C submits empty so the agent unblocks.
|
||||
*/
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
export function MaskedPrompt(props: {
|
||||
icon: string
|
||||
label: string
|
||||
sub?: string
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const [value, setValue] = createSignal('')
|
||||
|
||||
useKeyboard(key => {
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
props.onCancel()
|
||||
return
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
props.onSubmit(value())
|
||||
return
|
||||
}
|
||||
if (key.name === 'backspace') {
|
||||
setValue(v => v.slice(0, -1))
|
||||
return
|
||||
}
|
||||
const ch = key.sequence ?? ''
|
||||
if (ch.length === 1 && !key.ctrl && !key.meta && ch >= ' ') setValue(v => v + ch)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.label}>
|
||||
<b>
|
||||
{props.icon} {props.label}
|
||||
</b>
|
||||
</text>
|
||||
<Show when={props.sub}>
|
||||
<text fg={theme().color.muted}>{props.sub}</text>
|
||||
</Show>
|
||||
<box style={{ flexDirection: 'row' }}>
|
||||
<text fg={theme().color.label}>{'> '}</text>
|
||||
<text fg={theme().color.text}>{'*'.repeat(value().length)}</text>
|
||||
<text fg={theme().color.accent}>▍</text>
|
||||
</box>
|
||||
<text fg={theme().color.muted}>Enter send · Esc/Ctrl+C cancel · masked</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
110
ui-opentui/src/view/prompts/promptOverlay.tsx
Normal file
110
ui-opentui/src/view/prompts/promptOverlay.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* PromptOverlay — renders the active blocking prompt and binds each answer/cancel
|
||||
* to the matching `*.respond` RPC (spec §4 reply contract; §8 #6 deadlock fix):
|
||||
* clarify.respond {answer, request_id} · approval.respond {choice, session_id} ·
|
||||
* sudo.respond {password, request_id} · secret.respond {value, request_id}.
|
||||
* Every cancel path (Esc/Ctrl+C) sends the deny/empty reply so the agent unblocks.
|
||||
*
|
||||
* `onRespond` is the entry-wired boundary callback (fires `gateway.request`); the
|
||||
* overlay also clears the store prompt so the composer returns. Narrowing is done
|
||||
* with reactive `as*()` accessors so each sub-prompt gets its typed payload.
|
||||
*/
|
||||
import { Match, Switch } from 'solid-js'
|
||||
|
||||
import { deferClose } from '../../logic/defer.ts'
|
||||
import type { ActivePrompt, SessionStore } from '../../logic/store.ts'
|
||||
import { ApprovalPrompt } from './approvalPrompt.tsx'
|
||||
import { ClarifyPrompt } from './clarifyPrompt.tsx'
|
||||
import { ConfirmPrompt } from './confirmPrompt.tsx'
|
||||
import { MaskedPrompt } from './maskedPrompt.tsx'
|
||||
|
||||
export interface PromptOverlayProps {
|
||||
readonly store: SessionStore
|
||||
readonly onRespond: (method: string, params: Record<string, unknown>) => void
|
||||
readonly sessionId: () => string | undefined
|
||||
}
|
||||
|
||||
export function PromptOverlay(props: PromptOverlayProps) {
|
||||
const prompt = () => props.store.state.prompt
|
||||
// Defer the prompt-clear (which remounts + refocuses the composer) past the
|
||||
// CURRENT keystroke, so the key that answered the prompt (Enter/y/select) can't
|
||||
// leak into the freshly-focused composer (e.g. `/clear`→y left "y" in the input).
|
||||
const clearSoon = () => deferClose(() => props.store.clearPrompt())
|
||||
const respond = (method: string, params: Record<string, unknown>) => {
|
||||
props.onRespond(method, params)
|
||||
clearSoon()
|
||||
}
|
||||
|
||||
// Reactive accessor that narrows the active-prompt union to one `kind`, giving
|
||||
// each <Match> branch its precise typed payload (undefined when not that kind).
|
||||
function narrow<K extends ActivePrompt['kind']>(kind: K): () => Extract<ActivePrompt, { kind: K }> | undefined {
|
||||
const matches = (p: ActivePrompt): p is Extract<ActivePrompt, { kind: K }> => p.kind === kind
|
||||
return () => {
|
||||
const p = prompt()
|
||||
return p && matches(p) ? p : undefined
|
||||
}
|
||||
}
|
||||
const asApproval = narrow('approval')
|
||||
const asClarify = narrow('clarify')
|
||||
const asSudo = narrow('sudo')
|
||||
const asSecret = narrow('secret')
|
||||
const asConfirm = narrow('confirm')
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={asApproval()}>
|
||||
{p => (
|
||||
<ApprovalPrompt
|
||||
command={p().command}
|
||||
description={p().description}
|
||||
onChoose={choice => respond('approval.respond', { choice, session_id: props.sessionId() })}
|
||||
onCancel={() => respond('approval.respond', { choice: 'deny', session_id: props.sessionId() })}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={asClarify()}>
|
||||
{p => (
|
||||
<ClarifyPrompt
|
||||
question={p().question}
|
||||
choices={p().choices}
|
||||
onAnswer={answer => respond('clarify.respond', { answer, request_id: p().requestId })}
|
||||
onCancel={() => respond('clarify.respond', { answer: '', request_id: p().requestId })}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={asSudo()}>
|
||||
{p => (
|
||||
<MaskedPrompt
|
||||
icon="🔐"
|
||||
label="sudo password"
|
||||
onSubmit={value => respond('sudo.respond', { password: value, request_id: p().requestId })}
|
||||
onCancel={() => respond('sudo.respond', { password: '', request_id: p().requestId })}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={asSecret()}>
|
||||
{p => (
|
||||
<MaskedPrompt
|
||||
icon="🔑"
|
||||
label={`Secret: ${p().envVar}`}
|
||||
sub={p().prompt}
|
||||
onSubmit={value => respond('secret.respond', { request_id: p().requestId, value })}
|
||||
onCancel={() => respond('secret.respond', { request_id: p().requestId, value: '' })}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={asConfirm()}>
|
||||
{p => (
|
||||
<ConfirmPrompt
|
||||
message={p().message}
|
||||
onYes={() => {
|
||||
p().onConfirm()
|
||||
clearSoon()
|
||||
}}
|
||||
onNo={clearSoon}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
74
ui-opentui/src/view/reasoningPart.tsx
Normal file
74
ui-opentui/src/view/reasoningPart.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* ReasoningPart — the model's thinking trace, collapsible (item 6; opencode's
|
||||
* ReasoningPart/ReasoningHeader). Auto-EXPANDED while the turn streams (so you
|
||||
* watch it think), then COLLAPSES to a one-line `▶ Thought: <title>` once the
|
||||
* turn settles. Click the header to override either way.
|
||||
*
|
||||
* ▼ Thinking: <title> ← live (streaming), body shown
|
||||
* ▶ Thought: <title> ← settled (collapsed), click to reopen
|
||||
* │ <reasoning markdown> ← dim body in a left-bordered block
|
||||
*
|
||||
* Title is the model's leading `**bold**` line when present (opencode's
|
||||
* reasoningSummary). Dim throughout — it's secondary to the answer.
|
||||
*/
|
||||
import { createMemo, createSignal, Show } from 'solid-js'
|
||||
|
||||
import { Markdown } from './markdown.tsx'
|
||||
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
const GUTTER = 2
|
||||
|
||||
/** Split a leading `**Title**\n\n body` into {title, body} (opencode reasoningSummary). */
|
||||
function reasoningSummary(text: string): { title?: string; body: string } {
|
||||
const s = (text ?? '').replace('[REDACTED]', '').trim()
|
||||
const m = s.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
|
||||
const title = m?.[1]?.trim()
|
||||
if (!m || !title) return { body: s }
|
||||
return { title, body: s.slice(m[0].length).trimStart() }
|
||||
}
|
||||
|
||||
export function ReasoningPart(props: { text: string; streaming?: boolean }) {
|
||||
const theme = useTheme()
|
||||
const anchor = useScrollAnchor()
|
||||
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
|
||||
// live → expanded so you see it think; settled → collapsed. Click overrides.
|
||||
const expanded = () => override() ?? !!props.streaming
|
||||
const toggle = () => anchor(() => setOverride(e => !(e ?? !!props.streaming)))
|
||||
const summary = createMemo(() => reasoningSummary(props.text))
|
||||
const label = () => (props.streaming ? 'Thinking' : 'Thought')
|
||||
|
||||
return (
|
||||
<Show when={summary().body || summary().title}>
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={toggle}>
|
||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.accent }}>{expanded() ? '▼' : '▶'}</span>
|
||||
</text>
|
||||
</box>
|
||||
{/* the header is a collapsible-section LABEL (Thinking/Thought + title)
|
||||
— chrome, not the reasoning body — so a free-form drag yields only
|
||||
the markdown body below, not the section label (item 4). */}
|
||||
<text selectable={false}>
|
||||
{/* accent chevron marks it; muted label keeps reasoning in the dim,
|
||||
secondary tier alongside tool calls (Ink hierarchy). */}
|
||||
<span style={{ fg: theme().color.muted }}>{label()}</span>
|
||||
<Show when={summary().title}>
|
||||
<span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={expanded() && summary().body}>
|
||||
<box
|
||||
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
|
||||
border={['left']}
|
||||
borderColor={theme().color.border}
|
||||
>
|
||||
<Markdown text={summary().body} streaming={props.streaming ?? false} fg={theme().color.muted} />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
54
ui-opentui/src/view/scrollAnchor.tsx
Normal file
54
ui-opentui/src/view/scrollAnchor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Scroll anchoring for collapse/expand toggles (item #4). The transcript
|
||||
* <scrollbox> has stickyScroll+stickyStart="bottom": on a content-height change
|
||||
* it re-pins to the bottom whenever the user hasn't manually scrolled away
|
||||
* (@opentui/core ScrollBox: `if (stickyStart && !_hasManualScroll) applyStickyStart`).
|
||||
* So expanding a tool/thinking block while at the bottom yanks the viewport to the
|
||||
* NEW bottom — scrolling the header you just clicked up off-screen.
|
||||
*
|
||||
* Fix: keep scrollTop constant across the toggle. The clicked element's document
|
||||
* position is unchanged (content grows BELOW it), so holding scrollTop keeps that
|
||||
* header at the same screen row and simply reveals the expansion beneath it. We
|
||||
* re-assert the saved offset over a few frames because the content height (and the
|
||||
* sticky re-pin) only settle on the next render pass.
|
||||
*/
|
||||
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
|
||||
|
||||
import type { ScrollBoxRenderable } from '@opentui/core'
|
||||
|
||||
type AnchorFn = (toggle: () => void) => void
|
||||
|
||||
const Ctx = createContext<AnchorFn>()
|
||||
|
||||
export function ScrollAnchorProvider(props: {
|
||||
scroll: Accessor<ScrollBoxRenderable | undefined>
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const around: AnchorFn = toggle => {
|
||||
const sb = props.scroll()
|
||||
if (!sb) {
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
const prev = sb.scrollTop
|
||||
toggle()
|
||||
// Re-assert across the next few frames: the layout + sticky re-pin land on
|
||||
// subsequent render passes, so a single sync restore wouldn't hold.
|
||||
let n = 0
|
||||
const hold = () => {
|
||||
try {
|
||||
sb.scrollTo(prev)
|
||||
} catch {
|
||||
/* renderable torn down */
|
||||
}
|
||||
if (++n < 4) setTimeout(hold, 16)
|
||||
}
|
||||
setTimeout(hold, 0)
|
||||
}
|
||||
return <Ctx.Provider value={around}>{props.children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
/** Wrap a collapse/expand toggle so the viewport stays put (no-op outside a provider). */
|
||||
export function useScrollAnchor(): AnchorFn {
|
||||
return useContext(Ctx) ?? (toggle => toggle())
|
||||
}
|
||||
144
ui-opentui/src/view/statusBar.tsx
Normal file
144
ui-opentui/src/view/statusBar.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* StatusBar — the persistent bottom chrome (spec §3; Ink's `appChrome.tsx`
|
||||
* StatusRule, item 14). One themed row pinned below the input zone:
|
||||
*
|
||||
* ● model ·effort ████░░░░ 42% ~/dir (branch)
|
||||
*
|
||||
* Fields are sourced from `store.state.info` (the `session.info` event +
|
||||
* session.create/resume result; see store `SessionInfo`). Width-aware (Ink's
|
||||
* `statusRuleWidths` progressive disclosure): the context bar drops on narrow
|
||||
* terminals and the cwd is left-truncated (`…/tail`) so the row NEVER wraps or
|
||||
* clips. Read-only chrome — no input handling here.
|
||||
*/
|
||||
import { useDimensions } from './dimensions.tsx'
|
||||
import { createMemo, Show } from 'solid-js'
|
||||
|
||||
import { useTheme } from './theme.tsx'
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
|
||||
const HOME = process.env.HOME ?? ''
|
||||
const CTX_BAR_CELLS = 8
|
||||
|
||||
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`; trims the provider prefix (Ink shortModelLabel). */
|
||||
function shortModel(model: string): string {
|
||||
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model
|
||||
}
|
||||
|
||||
/** Reasoning effort → a compact suffix; hidden for the default/medium effort. */
|
||||
function effortSuffix(effort: string | undefined, fast: boolean | undefined): string {
|
||||
const parts: string[] = []
|
||||
if (effort && effort !== 'medium' && effort !== 'default') parts.push(effort)
|
||||
if (fast) parts.push('fast')
|
||||
return parts.length ? ` ·${parts.join('·')}` : ''
|
||||
}
|
||||
|
||||
/** Abbreviate cwd with `~` for $HOME, then collapse to the last two path segments
|
||||
* (`…/lively-thrush/hermes-agent`) so deep worktree paths stay readable (Ink fmtCwdBranch). */
|
||||
function shortCwd(cwd: string): string {
|
||||
const home = HOME && (cwd === HOME || cwd.startsWith(HOME + '/')) ? '~' + cwd.slice(HOME.length) : cwd
|
||||
const segs = home.split('/').filter(Boolean)
|
||||
return segs.length <= 3 ? home : '…/' + segs.slice(-2).join('/')
|
||||
}
|
||||
|
||||
/** Keep the TAIL of a string, prefixing with `…` when it must be clipped. */
|
||||
function truncLeft(s: string, max: number): string {
|
||||
if (max <= 1) return s.length > max ? '…' : s
|
||||
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||
}
|
||||
|
||||
/** A unicode meter: `████░░░░` filled to `pct`% over `width` cells (Ink ctxBar). */
|
||||
function ctxBar(pct: number, width: number): string {
|
||||
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
|
||||
return '█'.repeat(filled) + '░'.repeat(width - filled)
|
||||
}
|
||||
|
||||
export function StatusBar(props: { store: SessionStore }) {
|
||||
const theme = useTheme()
|
||||
const dims = useDimensions()
|
||||
const info = () => props.store.state.info
|
||||
|
||||
// Context-bar colour escalates with pressure (Ink ctxBarColor good→warn→bad→critical).
|
||||
const ctxColor = (pct: number) =>
|
||||
pct >= 92
|
||||
? theme().color.statusCritical
|
||||
: pct >= 80
|
||||
? theme().color.statusBad
|
||||
: pct >= 60
|
||||
? theme().color.statusWarn
|
||||
: theme().color.statusGood
|
||||
|
||||
const dot = () => (info().running ? '◐' : props.store.state.ready ? '●' : '○')
|
||||
const dotColor = () =>
|
||||
info().running ? theme().color.statusWarn : props.store.state.ready ? theme().color.statusGood : theme().color.muted
|
||||
|
||||
const model = () => {
|
||||
const m = info().model
|
||||
return m ? shortModel(m) : ''
|
||||
}
|
||||
const effort = () => effortSuffix(info().effort, info().fast)
|
||||
const pct = () => info().contextPercent
|
||||
|
||||
// Progressive disclosure budget (the row is `width - 2` after the box padding).
|
||||
// left = dot+space+model+effort ; the context bar shows only when there's room.
|
||||
const showBar = createMemo(() => pct() !== undefined && dims().width >= 64)
|
||||
const ctxText = () => {
|
||||
const p = pct()
|
||||
return showBar() && p !== undefined ? `${ctxBar(p, CTX_BAR_CELLS)} ${p}%` : ''
|
||||
}
|
||||
|
||||
// Right side: cwd (branch), left-truncated to whatever the left side leaves.
|
||||
const cwdFull = createMemo(() => {
|
||||
const cwd = info().cwd
|
||||
const c = cwd ? shortCwd(cwd) : ''
|
||||
if (!c) return ''
|
||||
return info().branch ? `${c} (${info().branch})` : c
|
||||
})
|
||||
const rightText = createMemo(() => {
|
||||
const leftLen = 2 + model().length + effort().length + (showBar() ? ctxText().length + 3 : 0)
|
||||
const budget = dims().width - 2 - leftLen - 2 // box padding + a 2-col gap
|
||||
return budget > 4 ? truncLeft(cwdFull(), budget) : ''
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: theme().color.statusBg,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1
|
||||
}}
|
||||
>
|
||||
{/* left: turn/connection dot + model + effort + context bar */}
|
||||
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: dotColor() }}>{dot()}</span>
|
||||
<Show when={model()}>
|
||||
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
|
||||
<span style={{ fg: theme().color.muted }}>{effort()}</span>
|
||||
</Show>
|
||||
<Show when={showBar()}>
|
||||
{/* a dim divider segments the bar into scannable fields (item 8).
|
||||
showBar() already guarantees pct() is defined; `?? 0` only
|
||||
satisfies the type and is never reached. */}
|
||||
<span style={{ fg: theme().color.border }}>{' │ '}</span>
|
||||
<span style={{ fg: ctxColor(pct() ?? 0) }}>{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}</span>
|
||||
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* spacer pushes the cwd to the right edge */}
|
||||
<box style={{ flexGrow: 1, minWidth: 0 }} />
|
||||
|
||||
{/* right: cwd (branch), pre-truncated so the row never wraps */}
|
||||
<Show when={rightText()}>
|
||||
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
31
ui-opentui/src/view/statusLine.tsx
Normal file
31
ui-opentui/src/view/statusLine.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* StatusLine — the transient line just below the transcript (spec §3 chrome).
|
||||
* Shows EITHER:
|
||||
* - a `hint` (e.g. "Ctrl+C again to quit" — item 11), in the warn colour and
|
||||
* taking priority; or
|
||||
* - the kaomoji busy face/verb from `thinking.delta`/`status.update` WHILE a
|
||||
* turn runs (Ink's FaceTicker), dim, cleared on `message.complete`.
|
||||
* This keeps those transient indicators OUT of the transcript. Renders nothing
|
||||
* when both are idle.
|
||||
*/
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
export function StatusLine(props: { store: SessionStore }) {
|
||||
const theme = useTheme()
|
||||
const line = () => props.store.state.hint ?? props.store.state.status
|
||||
const isHint = () => props.store.state.hint !== undefined
|
||||
return (
|
||||
<Show when={line()}>
|
||||
{text => (
|
||||
<box style={{ flexShrink: 0 }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
29
ui-opentui/src/view/theme.tsx
Normal file
29
ui-opentui/src/view/theme.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* ThemeProvider — the Solid context that exposes the current Theme to the view
|
||||
* (spec v4 §7.5; mirrors opencode `context/theme.tsx`). The view reads
|
||||
* `useTheme()().color.*` / `.brand.*` and NEVER hardcodes styles.
|
||||
*
|
||||
* The theme is a reactive accessor: when the boundary applies a skin
|
||||
* (gateway.ready{skin} / skin.changed → store updates the theme), Solid
|
||||
* fine-grained reactivity re-styles only the affected cells.
|
||||
*/
|
||||
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
|
||||
|
||||
import { DEFAULT_THEME, type Theme } from '../logic/theme.ts'
|
||||
|
||||
const ThemeContext = createContext<Accessor<Theme>>(() => DEFAULT_THEME)
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
/** Reactive theme accessor (from the store). Defaults to DEFAULT_THEME if omitted. */
|
||||
readonly theme?: Accessor<Theme>
|
||||
readonly children: JSX.Element
|
||||
}
|
||||
|
||||
export function ThemeProvider(props: ThemeProviderProps) {
|
||||
return <ThemeContext.Provider value={props.theme ?? (() => DEFAULT_THEME)}>{props.children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
/** Read the current theme inside a component. Call it (`useTheme()()`) to get the Theme. */
|
||||
export function useTheme(): Accessor<Theme> {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
204
ui-opentui/src/view/toolPart.tsx
Normal file
204
ui-opentui/src/view/toolPart.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* ToolPart — one tool call, rendered COLLAPSED by default with a clear expand
|
||||
* affordance (items 2 + 7). The header shows the tool's PRIMARY ARG inline so
|
||||
* you can read what it did without expanding (item 2 — "I don't see tool args"):
|
||||
*
|
||||
* ▶ terminal ls -la src · 0.3s (12 lines) ← collapsed (default)
|
||||
* ▼ terminal ls -la src · 0.3s ← expanded header
|
||||
* │ args { … } ← full args (when present)
|
||||
* │ output … ← envelope-stripped body
|
||||
* │ … omitted 5 lines / 234 chars ← tidy note (no raw label)
|
||||
*
|
||||
* `▶`/`▼` marks expandable tools; clicking the header toggles it. Running tools
|
||||
* show `name …`. `resultText`/`omittedNote` are already cleaned by the store.
|
||||
* Fully themed (no hardcoded styles); decorative glyphs are selectable={false}.
|
||||
*/
|
||||
import { type ToolPartState } from '../logic/store.ts'
|
||||
import { useDimensions } from './dimensions.tsx'
|
||||
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||
|
||||
import { collapseToolOutput, truncate } from '../logic/toolOutput.ts'
|
||||
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
const GUTTER = 2
|
||||
/** Max output lines shown when expanded (a sane cap to avoid huge renders). */
|
||||
const EXPANDED_MAX = 200
|
||||
/** Max args lines shown when expanded. */
|
||||
const ARGS_MAX = 16
|
||||
|
||||
function fmtDuration(s: number): string {
|
||||
if (s < 10) return `${s.toFixed(1)}s`
|
||||
if (s < 60) return `${Math.round(s)}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const r = Math.round(s % 60)
|
||||
return r ? `${m}m ${r}s` : `${m}m`
|
||||
}
|
||||
|
||||
export function ToolPart(props: { part: ToolPartState }) {
|
||||
const theme = useTheme()
|
||||
const dims = useDimensions()
|
||||
const anchor = useScrollAnchor()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const toggle = () => anchor(() => setExpanded(e => !e))
|
||||
|
||||
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)
|
||||
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
|
||||
const lines = () => (result() ? result().split('\n') : [])
|
||||
const running = () => props.part.state === 'running'
|
||||
const hasOutput = () => lines().length > 0
|
||||
// Parse the args JSON into top-level key→value entries for a tidy key:value
|
||||
// render (no brace noise). Falls back to raw lines when it isn't an object.
|
||||
const argsObj = createMemo<Record<string, unknown> | undefined>(() => {
|
||||
const t = props.part.argsText
|
||||
if (!t) return undefined
|
||||
try {
|
||||
const o: unknown = JSON.parse(t)
|
||||
return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, unknown>) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
const argLine = (k: string, v: unknown) =>
|
||||
`${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`.replace(/\s+/g, ' ')
|
||||
const argEntries = createMemo(() => Object.entries(argsObj() ?? {}))
|
||||
// Hide the args block when it adds nothing over the header: a single field
|
||||
// whose value is already the primary-arg preview (item 2 judge nit — terminal's
|
||||
// `command` is redundant). Show it for multi-field tools (edits, reads w/ range).
|
||||
const showArgs = createMemo(() => {
|
||||
const e = argEntries()
|
||||
if (argsObj() === undefined) return !!props.part.argsText // unparsed → show raw
|
||||
if (e.length === 0) return false
|
||||
const only = e.length === 1 ? e[0] : undefined
|
||||
if (only) {
|
||||
const v = only[1]
|
||||
const vs = (typeof v === 'string' ? v : JSON.stringify(v)).trim()
|
||||
return vs !== (props.part.argsPreview ?? '').trim()
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Expandable when there's a body to reveal beyond the header (output or args).
|
||||
const collapsible = () => !running() && (lines().length > 1 || showArgs())
|
||||
// Header subtitle: the primary-arg preview (item 2), else explicit summary, else first line.
|
||||
const subtitle = () =>
|
||||
props.part.error ? `✗ ${props.part.error}` : props.part.argsPreview || props.part.summary || lines()[0] || ''
|
||||
const body = createMemo(() => collapseToolOutput(result(), EXPANDED_MAX, bodyWidth() - 2))
|
||||
|
||||
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
||||
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
|
||||
// as the dim, secondary tier below the bright assistant answer (Ink hierarchy).
|
||||
const headColor = () => (props.part.error ? theme().color.error : theme().color.accent)
|
||||
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
|
||||
|
||||
return (
|
||||
// Spacing between parts is owned by the parts column (gap), not per-part
|
||||
// margins — so a tool appearing mid-stream doesn't shift the layout (item 5).
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
|
||||
{/* header — clickable to toggle when there's expandable output/args */}
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && toggle()}>
|
||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: headColor() }}>{headGlyph()}</span>
|
||||
</text>
|
||||
</box>
|
||||
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0 }}>
|
||||
{/* the whole header row is a collapsed SUMMARY (tool name + args-preview
|
||||
+ duration + "(N lines)") — chrome, not the copyable body — so a
|
||||
free-form drag over a tool yields only the expanded output/args
|
||||
content, never the header label (item 4). */}
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{props.part.name}</span>
|
||||
<Show when={running()}>
|
||||
<span style={{ fg: theme().color.muted }}> …</span>
|
||||
</Show>
|
||||
<Show when={!running() && subtitle()}>
|
||||
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
|
||||
{` ${truncate(subtitle(), subWidth())}`}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!running() && props.part.duration !== undefined}>
|
||||
<span style={{ fg: theme().color.muted }}>{` · ${fmtDuration(props.part.duration ?? 0)}`}</span>
|
||||
</Show>
|
||||
<Show when={collapsible() && !expanded() && lines().length > 1}>
|
||||
<span style={{ fg: theme().color.muted }}>{` (${lines().length} lines)`}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* expanded body — args block (when present) then output block, inside a
|
||||
single left-bordered column (a `│` rule, not a bg fill — opencode's
|
||||
BlockTool style; also renders faithfully and reads cleaner). */}
|
||||
<Show when={collapsible() && expanded()}>
|
||||
<box
|
||||
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
|
||||
border={['left']}
|
||||
borderColor={props.part.error ? theme().color.error : theme().color.border}
|
||||
>
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
|
||||
<Show when={showArgs()}>
|
||||
{/* section label — chrome, not content (item 4) */}
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.label }}>args</span>
|
||||
</text>
|
||||
{/* parsed key: value lines (tidy), or raw argsText when unparseable */}
|
||||
<Show
|
||||
when={argsObj() !== undefined}
|
||||
fallback={
|
||||
<For each={(props.part.argsText ?? '').split('\n').slice(0, ARGS_MAX)}>
|
||||
{line => (
|
||||
<text selectionBg={theme().color.selectionBg}>
|
||||
<span style={{ fg: theme().color.muted }}>{truncate(line, bodyWidth() - 2)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={argEntries().slice(0, ARGS_MAX)}>
|
||||
{([k, v]) => (
|
||||
<text selectionBg={theme().color.selectionBg}>
|
||||
<span style={{ fg: theme().color.muted }}>{truncate(argLine(k, v), bodyWidth() - 2)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<Show when={argEntries().length > ARGS_MAX}>
|
||||
{/* overflow annotation — chrome, not content (item 4) */}
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.accent }}>{`… +${argEntries().length - ARGS_MAX} more`}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={showArgs() && hasOutput()}>
|
||||
{/* section label — chrome, not content (item 4) */}
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.label }}>output</span>
|
||||
</text>
|
||||
</Show>
|
||||
{/* output body lines are the copyable content → themed selection bar
|
||||
(preserves fg; same token as message text) (item: theme highlight). */}
|
||||
<For each={body().lines}>
|
||||
{line => (
|
||||
<text selectionBg={theme().color.selectionBg}>
|
||||
<span style={{ fg: theme().color.muted }}>{line}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
{/* truncation annotations — chrome (the "… omitted N" / "… +N more
|
||||
lines" notes are not part of the real output body) (item 4). */}
|
||||
<Show when={props.part.omittedNote}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{`… omitted ${props.part.omittedNote}`}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={body().hiddenLines > 0 && !props.part.omittedNote}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.accent }}>{`… +${body().hiddenLines} more lines`}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
53
ui-opentui/src/view/transcript.tsx
Normal file
53
ui-opentui/src/view/transcript.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Transcript — the scrolling message pane (spec v4 §2 `view/transcript.tsx`).
|
||||
*
|
||||
* ONE full-height <scrollbox> with a reactive <For> (opencode's model — the
|
||||
* viewport clips growing output so terminal scrollback is never corrupted; no
|
||||
* `writeToScrollback`). Carries the §8 #2 gotchas EXACTLY:
|
||||
* - `minHeight:0` on BOTH the wrapper box AND the <scrollbox> (so the flex
|
||||
* child can shrink below content height instead of pushing the composer off),
|
||||
* - NO `flexDirection` on the <scrollbox> ROOT style (it has internal
|
||||
* viewport/content children; setting it there breaks content-height
|
||||
* measurement → phantom scroll offset that clips the top + leaves a gap),
|
||||
* - `stickyScroll` + `stickyStart="bottom"` to pin the latest line.
|
||||
*
|
||||
* A `ScrollAnchorProvider` gives collapse/expand toggles (tool/thinking) a handle
|
||||
* to hold the viewport in place so expanding doesn't yank to the bottom (#4).
|
||||
*/
|
||||
import type { ScrollBoxRenderable } from '@opentui/core'
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { HomeHint } from './homeHint.tsx'
|
||||
import { MessageLine } from './messageLine.tsx'
|
||||
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
export function Transcript(props: { store: SessionStore }) {
|
||||
const [scroll, setScroll] = createSignal<ScrollBoxRenderable | undefined>()
|
||||
const theme = useTheme()
|
||||
const dropped = () => props.store.state.dropped
|
||||
const sid = () => props.store.state.sessionId
|
||||
return (
|
||||
<box style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
||||
<ScrollAnchorProvider scroll={scroll}>
|
||||
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
|
||||
<Show when={props.store.state.messages.length === 0}>
|
||||
<HomeHint store={props.store} />
|
||||
</Show>
|
||||
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the
|
||||
DISPLAY (never the model's context — that lives on the gateway). Point to
|
||||
the dashboard for the full transcript. selectable=false → it's chrome,
|
||||
excluded from copy/selection. */}
|
||||
<Show when={dropped() > 0}>
|
||||
<text selectable={false} style={{ fg: theme().color.muted }}>
|
||||
{`⤒ ${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
|
||||
</ScrollAnchorProvider>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
29
ui-opentui/tsconfig.json
Normal file
29
ui-opentui/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitReturns": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src", "scripts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
59
ui-opentui/vitest.config.ts
Normal file
59
ui-opentui/vitest.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Vitest config for the Node-26 engine (replaces `bun test`).
|
||||
*
|
||||
* Same Solid transform as the production build (scripts/build.mjs): app .tsx/.jsx
|
||||
* go through babel-preset-solid in `generate:"universal"` mode with
|
||||
* `moduleName:"@opentui/solid"`, and solid-js resolves to its CLIENT build (the
|
||||
* package `node` condition points at the SSR `server.js`, which lacks the
|
||||
* universal reactive primitives). See docs/plans/opentui-node26-build-spec.md.
|
||||
*
|
||||
* render.test.tsx mounts the native @opentui/solid test renderer, so the test
|
||||
* forks need `--experimental-ffi`. We inject it into NODE_OPTIONS here (the config
|
||||
* runs in vitest's main process before it forks workers, which inherit the env) —
|
||||
* self-contained and cross-platform, no shell wrapper needed. The other suites are
|
||||
* pure logic.
|
||||
*/
|
||||
import { transformAsync } from '@babel/core'
|
||||
import tsPreset from '@babel/preset-typescript'
|
||||
import solidPreset from 'babel-preset-solid'
|
||||
import { createRequire } from 'node:module'
|
||||
import type { Plugin } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Ensure forked test workers load OpenTUI's native core via node:ffi.
|
||||
const ffiOpts = '--experimental-ffi --no-warnings'
|
||||
if (!(process.env.NODE_OPTIONS ?? '').includes('--experimental-ffi')) {
|
||||
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} ${ffiOpts}`.trim()
|
||||
}
|
||||
|
||||
const opentuiSolid = (): Plugin => ({
|
||||
name: 'opentui-solid',
|
||||
enforce: 'pre',
|
||||
async transform(code, id) {
|
||||
const path = id.split('?')[0]
|
||||
if (!/\.[cm]?[jt]sx$/.test(path) || path.includes('/node_modules/')) return null
|
||||
const out = await transformAsync(code, {
|
||||
filename: path,
|
||||
configFile: false,
|
||||
babelrc: false,
|
||||
sourceMaps: true,
|
||||
presets: [[solidPreset, { moduleName: '@opentui/solid', generate: 'universal' }], [tsPreset]]
|
||||
})
|
||||
return out?.code ? { code: out.code, map: out.map } : null
|
||||
}
|
||||
})
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [opentuiSolid()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^solid-js\/store$/, replacement: require.resolve('solid-js/store/dist/store.js') },
|
||||
{ find: /^solid-js$/, replacement: require.resolve('solid-js/dist/solid.js') }
|
||||
]
|
||||
},
|
||||
test: {
|
||||
include: ['src/test/**/*.test.{ts,tsx}']
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user