mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Merge pull request #4692 from NousResearch/feat/ink-refactor
Feat/ink refactor
This commit is contained in:
commit
e9b8ece103
272 changed files with 51548 additions and 938 deletions
4
.envrc
4
.envrc
|
|
@ -1 +1,5 @@
|
|||
watch_file pyproject.toml uv.lock
|
||||
watch_file ui-tui/package-lock.json ui-tui/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
|
||||
|
||||
use flake
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -60,5 +60,6 @@ mini-swe-agent/
|
|||
|
||||
# Nix
|
||||
.direnv/
|
||||
.nix-stamps/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
|
|
|
|||
66
AGENTS.md
66
AGENTS.md
|
|
@ -56,6 +56,19 @@ hermes-agent/
|
|||
│ ├── run.py # Main loop, slash commands, message dispatch
|
||||
│ ├── session.py # SessionStore — conversation persistence
|
||||
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal, qqbot
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
│ ├── src/entry.tsx # TTY gate + render()
|
||||
│ ├── src/app.tsx # Main state machine and UI
|
||||
│ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge
|
||||
│ ├── src/app/ # Decomposed app logic (event handler, slash handler, stores, hooks)
|
||||
│ ├── src/components/ # Ink components (branding, markdown, prompts, pickers, etc.)
|
||||
│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory
|
||||
│ └── src/lib/ # Pure helpers (history, osc52, text, rpc, messages)
|
||||
├── tui_gateway/ # Python JSON-RPC backend for the TUI
|
||||
│ ├── entry.py # stdio entrypoint
|
||||
│ ├── server.py # RPC handlers and session logic
|
||||
│ ├── render.py # Optional rich/ANSI bridge
|
||||
│ └── slash_worker.py # Persistent HermesCLI subprocess for slash commands
|
||||
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
|
||||
├── cron/ # Scheduler (jobs.py, scheduler.py)
|
||||
├── environments/ # RL training environments (Atropos)
|
||||
|
|
@ -179,6 +192,59 @@ if canonical == "mycommand":
|
|||
|
||||
---
|
||||
|
||||
## TUI Architecture (ui-tui + tui_gateway)
|
||||
|
||||
The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
|
||||
|
||||
### Process Model
|
||||
|
||||
```
|
||||
hermes --tui
|
||||
└─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
|
||||
│ └─ AIAgent + tools + sessions
|
||||
└─ renders transcript, composer, prompts, activity
|
||||
```
|
||||
|
||||
TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
|
||||
|
||||
### Transport
|
||||
|
||||
Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
|
||||
|
||||
### Key Surfaces
|
||||
|
||||
| Surface | Ink component | Gateway method |
|
||||
|---------|---------------|----------------|
|
||||
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
|
||||
| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
|
||||
| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
|
||||
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
|
||||
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
|
||||
| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
|
||||
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
|
||||
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
|
||||
|
||||
### Slash Command Flow
|
||||
|
||||
1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
|
||||
2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
|
||||
|
||||
### Dev Commands
|
||||
|
||||
```bash
|
||||
cd ui-tui
|
||||
npm install # first time
|
||||
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
|
||||
npm start # production
|
||||
npm run build # full build (hermes-ink + tsc)
|
||||
npm run type-check # typecheck only (tsc --noEmit)
|
||||
npm run lint # eslint
|
||||
npm run fmt # prettier
|
||||
npm test # vitest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
Requires changes in **2 files**:
|
||||
|
|
|
|||
|
|
@ -141,11 +141,18 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
|
|||
|
||||
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
|
||||
|
||||
Quick start for contributors:
|
||||
Quick start for contributors — clone and go with `setup-hermes.sh`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
|
||||
./hermes # auto-detects the venv, no need to `source` first
|
||||
```
|
||||
|
||||
Manual path (equivalent to the above):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv venv --python 3.11
|
||||
source venv/bin/activate
|
||||
|
|
|
|||
142
cli.py
142
cli.py
|
|
@ -18,6 +18,8 @@ import os
|
|||
import shutil
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import base64
|
||||
import atexit
|
||||
import tempfile
|
||||
import time
|
||||
|
|
@ -78,6 +80,42 @@ _project_env = Path(__file__).parent / '.env'
|
|||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
||||
|
||||
|
||||
_REASONING_TAGS = (
|
||||
"REASONING_SCRATCHPAD",
|
||||
"think",
|
||||
"reasoning",
|
||||
"THINKING",
|
||||
"thinking",
|
||||
)
|
||||
|
||||
|
||||
def _strip_reasoning_tags(text: str) -> str:
|
||||
cleaned = text
|
||||
for tag in _REASONING_TAGS:
|
||||
cleaned = re.sub(rf"<{tag}>.*?</{tag}>\s*", "", cleaned, flags=re.DOTALL)
|
||||
cleaned = re.sub(rf"<{tag}>.*$", "", cleaned, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _assistant_content_as_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
str(part.get("text", ""))
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
]
|
||||
return "\n".join(p for p in parts if p)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _assistant_copy_text(content: Any) -> str:
|
||||
return _strip_reasoning_tags(_assistant_content_as_text(content))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Configuration Loading
|
||||
# =============================================================================
|
||||
|
|
@ -1172,6 +1210,10 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
|
|||
return None
|
||||
|
||||
expanded = os.path.expandvars(os.path.expanduser(token))
|
||||
if os.name != "nt":
|
||||
normalized = expanded.replace("\\", "/")
|
||||
if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha():
|
||||
expanded = f"/mnt/{normalized[0].lower()}/{normalized[3:]}"
|
||||
path = Path(expanded)
|
||||
if not path.is_absolute():
|
||||
base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd()))
|
||||
|
|
@ -1254,10 +1296,12 @@ def _detect_file_drop(user_input: str) -> "dict | None":
|
|||
or stripped.startswith("~")
|
||||
or stripped.startswith("./")
|
||||
or stripped.startswith("../")
|
||||
or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha())
|
||||
or stripped.startswith('"/')
|
||||
or stripped.startswith('"~')
|
||||
or stripped.startswith("'/")
|
||||
or stripped.startswith("'~")
|
||||
or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha())
|
||||
)
|
||||
if not starts_like_path:
|
||||
return None
|
||||
|
|
@ -3125,21 +3169,6 @@ class HermesCLI:
|
|||
MAX_ASST_LEN = 200 # truncate assistant text
|
||||
MAX_ASST_LINES = 3 # max lines of assistant text
|
||||
|
||||
def _strip_reasoning(text: str) -> str:
|
||||
"""Remove <REASONING_SCRATCHPAD>...</REASONING_SCRATCHPAD> blocks
|
||||
from displayed text (reasoning model internal thoughts)."""
|
||||
import re
|
||||
cleaned = re.sub(
|
||||
r"<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>\s*",
|
||||
"", text, flags=re.DOTALL,
|
||||
)
|
||||
# Also strip unclosed reasoning tags at the end
|
||||
cleaned = re.sub(
|
||||
r"<REASONING_SCRATCHPAD>.*$",
|
||||
"", cleaned, flags=re.DOTALL,
|
||||
)
|
||||
return cleaned.strip()
|
||||
|
||||
# Collect displayable entries (skip system, tool-result messages)
|
||||
entries = [] # list of (role, display_text)
|
||||
_last_asst_idx = None # index of last assistant entry
|
||||
|
|
@ -3171,7 +3200,7 @@ class HermesCLI:
|
|||
|
||||
elif role == "assistant":
|
||||
text = "" if content is None else str(content)
|
||||
text = _strip_reasoning(text)
|
||||
text = _strip_reasoning_tags(text)
|
||||
parts = []
|
||||
full_parts = [] # un-truncated version
|
||||
if text:
|
||||
|
|
@ -3510,6 +3539,26 @@ class HermesCLI:
|
|||
killed = process_registry.kill_all()
|
||||
print(f" ✅ Stopped {killed} process(es).")
|
||||
|
||||
def _handle_agents_command(self):
|
||||
"""Handle /agents — show background processes and agent status."""
|
||||
from tools.process_registry import format_uptime_short, process_registry
|
||||
|
||||
processes = process_registry.list_sessions()
|
||||
running = [p for p in processes if p.get("status") == "running"]
|
||||
finished = [p for p in processes if p.get("status") != "running"]
|
||||
|
||||
_cprint(f" Running processes: {len(running)}")
|
||||
for p in running:
|
||||
cmd = p.get("command", "")[:80]
|
||||
up = format_uptime_short(p.get("uptime_seconds", 0))
|
||||
_cprint(f" {p.get('session_id', '?')} · {up} · {cmd}")
|
||||
|
||||
if finished:
|
||||
_cprint(f" Recently finished: {len(finished)}")
|
||||
|
||||
agent_running = getattr(self, "_agent_running", False)
|
||||
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
|
||||
|
||||
def _handle_paste_command(self):
|
||||
"""Handle /paste — explicitly check clipboard for an image.
|
||||
|
||||
|
|
@ -3535,6 +3584,61 @@ class HermesCLI:
|
|||
else:
|
||||
_cprint(f" {_DIM}(._.) No image found in clipboard{_RST}")
|
||||
|
||||
def _write_osc52_clipboard(self, text: str) -> None:
|
||||
"""Copy *text* to terminal clipboard via OSC 52."""
|
||||
payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
seq = f"\x1b]52;c;{payload}\x07"
|
||||
out = getattr(self, "_app", None)
|
||||
output = getattr(out, "output", None) if out else None
|
||||
if output and hasattr(output, "write_raw"):
|
||||
output.write_raw(seq)
|
||||
output.flush()
|
||||
return
|
||||
if output and hasattr(output, "write"):
|
||||
output.write(seq)
|
||||
output.flush()
|
||||
return
|
||||
sys.stdout.write(seq)
|
||||
sys.stdout.flush()
|
||||
|
||||
def _handle_copy_command(self, cmd_original: str) -> None:
|
||||
"""Handle /copy [number] — copy assistant output to clipboard."""
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
assistant = [m for m in self.conversation_history if m.get("role") == "assistant"]
|
||||
if not assistant:
|
||||
_cprint(" Nothing to copy yet.")
|
||||
return
|
||||
|
||||
if arg:
|
||||
try:
|
||||
idx = int(arg) - 1
|
||||
except ValueError:
|
||||
_cprint(" Usage: /copy [number]")
|
||||
return
|
||||
if idx < 0 or idx >= len(assistant):
|
||||
_cprint(f" Invalid response number. Use 1-{len(assistant)}.")
|
||||
return
|
||||
else:
|
||||
idx = len(assistant) - 1
|
||||
while idx >= 0 and not _assistant_copy_text(assistant[idx].get("content")):
|
||||
idx -= 1
|
||||
if idx < 0:
|
||||
_cprint(" Nothing to copy in assistant responses yet.")
|
||||
return
|
||||
|
||||
text = _assistant_copy_text(assistant[idx].get("content"))
|
||||
if not text:
|
||||
_cprint(" Nothing to copy in that assistant response.")
|
||||
return
|
||||
|
||||
try:
|
||||
self._write_osc52_clipboard(text)
|
||||
_cprint(f" Copied assistant response #{idx + 1} to clipboard")
|
||||
except Exception as e:
|
||||
_cprint(f" Clipboard copy failed: {e}")
|
||||
|
||||
def _handle_image_command(self, cmd_original: str):
|
||||
"""Handle /image <path> — attach a local image file for the next prompt."""
|
||||
raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
|
||||
|
|
@ -3671,7 +3775,7 @@ class HermesCLI:
|
|||
skin = get_active_skin()
|
||||
separator_color = skin.get_color("banner_dim", "#B8860B")
|
||||
accent_color = skin.get_color("ui_accent", "#FFBF00")
|
||||
label_color = skin.get_color("ui_label", "#4dd0e1")
|
||||
label_color = skin.get_color("ui_label", "#DAA520")
|
||||
except Exception:
|
||||
separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan"
|
||||
toolsets_info = ""
|
||||
|
|
@ -5553,6 +5657,8 @@ class HermesCLI:
|
|||
self._show_usage()
|
||||
elif canonical == "insights":
|
||||
self._show_insights(cmd_original)
|
||||
elif canonical == "copy":
|
||||
self._handle_copy_command(cmd_original)
|
||||
elif canonical == "debug":
|
||||
self._handle_debug_command()
|
||||
elif canonical == "paste":
|
||||
|
|
@ -5596,6 +5702,8 @@ class HermesCLI:
|
|||
self._handle_snapshot_command(cmd_original)
|
||||
elif canonical == "stop":
|
||||
self._handle_stop_command()
|
||||
elif canonical == "agents":
|
||||
self._handle_agents_command()
|
||||
elif canonical == "background":
|
||||
self._handle_background_command(cmd_original)
|
||||
elif canonical == "btw":
|
||||
|
|
|
|||
108
docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md
Normal file
108
docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Ink Gateway TUI Migration — Post-mortem
|
||||
|
||||
Planned: 2026-04-01 · Delivered: 2026-04 · Status: shipped, classic (prompt_toolkit) CLI still present
|
||||
|
||||
## What Shipped
|
||||
|
||||
Three layers, same repo, Python runtime unchanged.
|
||||
|
||||
```
|
||||
ui-tui (Node/TS) ──stdio JSON-RPC──▶ tui_gateway (Py) ──▶ AIAgent (run_agent.py)
|
||||
```
|
||||
|
||||
### Backend — `tui_gateway/`
|
||||
|
||||
```
|
||||
tui_gateway/
|
||||
├── entry.py # subprocess entrypoint, stdio read/write loop
|
||||
├── server.py # everything: sessions dict, @method handlers, _emit
|
||||
├── render.py # stream renderer, diff rendering, message rendering
|
||||
├── slash_worker.py # subprocess that runs hermes_cli slash commands
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
`server.py` owns the full runtime-control surface: session store (`_sessions: dict[str, dict]`), method registry (`@method("…")` decorator), event emitter (`_emit`), agent lifecycle (`_make_agent`, `_init_session`, `_wire_callbacks`), approval/sudo/clarify round-trips, and JSON-RPC dispatch.
|
||||
|
||||
Protocol methods (`@method(...)` in `server.py`):
|
||||
|
||||
- session: `session.{create, resume, list, close, interrupt, usage, history, compress, branch, title, save, undo}`
|
||||
- prompt: `prompt.{submit, background, btw}`
|
||||
- tools: `tools.{list, show, configure}`
|
||||
- slash: `slash.exec`, `command.{dispatch, resolve}`, `commands.catalog`, `complete.{path, slash}`
|
||||
- approvals: `approval.respond`, `sudo.respond`, `clarify.respond`, `secret.respond`
|
||||
- config/state: `config.{get, set, show}`, `model.options`, `reload.mcp`
|
||||
- ops: `shell.exec`, `cli.exec`, `terminal.resize`, `input.detect_drop`, `clipboard.paste`, `paste.collapse`, `image.attach`, `process.stop`
|
||||
- misc: `agents.list`, `skills.manage`, `plugins.list`, `cron.manage`, `insights.get`, `rollback.{list, diff, restore}`, `browser.manage`
|
||||
|
||||
Protocol events (`_emit(…)` → handled in `ui-tui/src/app/createGatewayEventHandler.ts`):
|
||||
|
||||
- lifecycle: `gateway.{ready, stderr}`, `session.info`, `skin.changed`
|
||||
- stream: `message.{start, delta, complete}`, `thinking.delta`, `reasoning.{delta, available}`, `status.update`
|
||||
- tools: `tool.{start, progress, complete, generating}`, `subagent.{start, thinking, tool, progress, complete}`
|
||||
- interactive: `approval.request`, `sudo.request`, `clarify.request`, `secret.request`
|
||||
- async: `background.complete`, `btw.complete`, `error`
|
||||
|
||||
### Frontend — `ui-tui/src/`
|
||||
|
||||
```
|
||||
src/
|
||||
├── entry.tsx # node bootstrap: bootBanner → spawn python → dynamic-import Ink → render(<App/>)
|
||||
├── app.tsx # <GatewayProvider> wraps <AppLayout>
|
||||
├── bootBanner.ts # raw-ANSI banner to stdout in ~2ms, pre-React
|
||||
├── gatewayClient.ts # JSON-RPC client over child_process stdio
|
||||
├── gatewayTypes.ts # typed RPC responses + GatewayEvent union
|
||||
├── theme.ts # DEFAULT_THEME + fromSkin
|
||||
│
|
||||
├── app/ # hooks + stores — the orchestration layer
|
||||
│ ├── uiStore.ts # nanostore: sid, info, busy, usage, theme, status…
|
||||
│ ├── turnStore.ts # nanostore: per-turn activity / reasoning / tools
|
||||
│ ├── turnController.ts # imperative singleton for stream-time operations
|
||||
│ ├── overlayStore.ts # nanostore: modal/overlay state
|
||||
│ ├── useMainApp.ts # top-level composition hook
|
||||
│ ├── useSessionLifecycle.ts # session.create/resume/close/reset
|
||||
│ ├── useSubmission.ts # shell/slash/prompt dispatch + interpolation
|
||||
│ ├── useConfigSync.ts # config.get + mtime poll
|
||||
│ ├── useComposerState.ts # input buffer, paste snippets, editor mode
|
||||
│ ├── useInputHandlers.ts # key bindings
|
||||
│ ├── createGatewayEventHandler.ts # event-stream dispatcher
|
||||
│ ├── createSlashHandler.ts # slash command router (registry + python fallback)
|
||||
│ └── slash/commands/ # core.ts, ops.ts, session.ts — TS-owned slash commands
|
||||
│
|
||||
├── components/ # AppLayout, AppChrome, AppOverlays, MessageLine, Thinking, Markdown, pickers, prompts, Banner, SessionPanel
|
||||
├── config/ # env, limits, timing constants
|
||||
├── content/ # charms, faces, fortunes, hotkeys, placeholders, verbs
|
||||
├── domain/ # details, messages, paths, roles, slash, usage, viewport
|
||||
├── protocol/ # interpolation, paste regex
|
||||
├── hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory
|
||||
└── lib/ # history, messages, osc52, rpc, text
|
||||
```
|
||||
|
||||
### CLI entry points — `hermes_cli/main.py`
|
||||
|
||||
- `hermes --tui` → `node dist/entry.js` (auto-builds when `.ts`/`.tsx` newer than `dist/entry.js`)
|
||||
- `hermes --tui --dev` → `tsx src/entry.tsx` (skip build)
|
||||
- `HERMES_TUI_DIR=…` → external prebuilt dist (nix, distro packaging)
|
||||
|
||||
## Diverged From Original Plan
|
||||
|
||||
| Plan | Reality | Why |
|
||||
|---|---|---|
|
||||
| `tui_gateway/{controller,session_state,events,protocol}.py` | all collapsed into `server.py` | no second consumer ever emerged, keeping one file cheaper than four |
|
||||
| `ui-tui/src/main.tsx` | split into `entry.tsx` (bootstrap) + `app.tsx` (shell) | boot banner + early python spawn wanted a pre-React moment |
|
||||
| `ui-tui/src/state/store.ts` | three nanostores (`uiStore`, `turnStore`, `overlayStore`) | separate lifetimes: ui persists, turn resets per reply, overlay is modal |
|
||||
| `approval.requested` / `sudo.requested` / `clarify.requested` | `*.request` (no `-ed`) | cosmetic |
|
||||
| `session.cancel` | dropped | `session.interrupt` covers it |
|
||||
| `HERMES_EXPERIMENTAL_TUI=1`, `display.experimental_tui: true`, `/tui on/off/status` | none shipped | `--tui` went from opt-in to first-class without an experimental phase |
|
||||
|
||||
## Post-migration Additions (not in original plan)
|
||||
|
||||
- **Async `session.create`** — returns sid in ~1ms, agent builds on a background thread, `session.info` broadcasts when ready; `_wait_agent()` gates every agent-touching handler via `_sess`
|
||||
- **`bootBanner`** — raw-ANSI logo painted to stdout at T≈2ms, before Ink loads; `<AlternateScreen>` wipes it seamlessly when React mounts
|
||||
- **Selection uniform bg** — `theme.color.selectionBg` wired via `useSelection().setSelectionBgColor`; replaces SGR-inverse per-cell swap that fragmented over amber/gold fg
|
||||
- **Slash command registry** — TS-owned commands in `app/slash/commands/{core,ops,session}.ts`, everything else falls through to `slash.exec` (python worker)
|
||||
- **Turn store + controller split** — imperative singleton (`turnController`) holds refs/timers, nanostore (`turnStore`) holds render-visible state
|
||||
|
||||
## What's Still Open
|
||||
|
||||
- **Classic CLI not deleted.** `cli.py` still has ~80 `prompt_toolkit` references; classic REPL is still the default when `--tui` is absent. The original plan's "Cut 4 · prompt_toolkit removal later" hasn't happened.
|
||||
- **No config-file opt-in.** `HERMES_EXPERIMENTAL_TUI` and `display.experimental_tui` were never built; only the CLI flag exists. Fine for now — if we want "default to TUI", a single line in `main.py` flips it.
|
||||
|
|
@ -6,6 +6,11 @@
|
|||
# All fields are optional — missing values inherit from the default skin.
|
||||
# Activate with: /skin <name> or display.skin: <name> in config.yaml
|
||||
#
|
||||
# Keys are marked:
|
||||
# (both) — applies to both the classic CLI and the TUI
|
||||
# (classic) — classic CLI only (see hermes --tui in user-guide/tui.md)
|
||||
# (tui) — TUI only
|
||||
#
|
||||
# See hermes_cli/skin_engine.py for the full schema reference.
|
||||
# ============================================================================
|
||||
|
||||
|
|
@ -14,43 +19,47 @@ name: example
|
|||
description: An example custom skin — copy and modify this template
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────────────
|
||||
# Hex color values for Rich markup. These control the CLI's visual palette.
|
||||
# Hex color values. These control the visual palette.
|
||||
colors:
|
||||
# Banner panel (the startup welcome box)
|
||||
# Banner panel (the startup welcome box) — (both)
|
||||
banner_border: "#CD7F32" # Panel border
|
||||
banner_title: "#FFD700" # Panel title text
|
||||
banner_accent: "#FFBF00" # Section headers (Available Tools, Skills, etc.)
|
||||
banner_dim: "#B8860B" # Dim/muted text (separators, model info)
|
||||
banner_text: "#FFF8DC" # Body text (tool names, skill names)
|
||||
|
||||
# UI elements
|
||||
ui_accent: "#FFBF00" # General accent color
|
||||
# UI elements — (both)
|
||||
ui_accent: "#FFBF00" # General accent (falls back to banner_accent)
|
||||
ui_label: "#4dd0e1" # Labels
|
||||
ui_ok: "#4caf50" # Success indicators
|
||||
ui_error: "#ef5350" # Error indicators
|
||||
ui_warn: "#ffa726" # Warning indicators
|
||||
|
||||
# Input area
|
||||
prompt: "#FFF8DC" # Prompt text color
|
||||
input_rule: "#CD7F32" # Horizontal rule around input
|
||||
prompt: "#FFF8DC" # Prompt text / `❯` glyph color (both)
|
||||
input_rule: "#CD7F32" # Horizontal rule above input (classic)
|
||||
|
||||
# Response box
|
||||
response_border: "#FFD700" # Response box border (ANSI color)
|
||||
# Response box — (classic)
|
||||
response_border: "#FFD700" # Response box border
|
||||
|
||||
# Session display
|
||||
session_label: "#DAA520" # Session label
|
||||
session_border: "#8B8682" # Session ID dim color
|
||||
# Session display — (both)
|
||||
session_label: "#DAA520" # "Session: " label
|
||||
session_border: "#8B8682" # Session ID text
|
||||
|
||||
# TUI surfaces
|
||||
status_bar_bg: "#1a1a2e" # Status / usage bar background
|
||||
voice_status_bg: "#1a1a2e" # Voice-mode badge background
|
||||
completion_menu_bg: "#1a1a2e" # Completion list background
|
||||
completion_menu_current_bg: "#333355" # Active completion row background
|
||||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
|
||||
completion_menu_meta_current_bg: "#333355" # Active completion meta background
|
||||
# TUI / CLI surfaces — (classic: status bar, voice badge, completion meta)
|
||||
status_bar_bg: "#1a1a2e" # Status / usage bar background (classic)
|
||||
voice_status_bg: "#1a1a2e" # Voice-mode badge background (classic)
|
||||
completion_menu_bg: "#1a1a2e" # Completion list background (both)
|
||||
completion_menu_current_bg: "#333355" # Active completion row background (both)
|
||||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column bg (classic)
|
||||
completion_menu_meta_current_bg: "#333355" # Active meta bg (classic)
|
||||
|
||||
# Drag-to-select background — (tui)
|
||||
selection_bg: "#3a3a55" # Uniform selection highlight in the TUI
|
||||
|
||||
# ── Spinner ─────────────────────────────────────────────────────────────────
|
||||
# Customize the animated spinner shown during API calls and tool execution.
|
||||
# (classic) — the TUI uses its own animated indicators; spinner config here
|
||||
# is only read by the classic prompt_toolkit CLI.
|
||||
spinner:
|
||||
# Faces shown while waiting for the API response
|
||||
waiting_faces:
|
||||
|
|
@ -78,17 +87,17 @@ spinner:
|
|||
# - ["⟪▲", "▲⟫"]
|
||||
|
||||
# ── Branding ────────────────────────────────────────────────────────────────
|
||||
# Text strings used throughout the CLI interface.
|
||||
# Text strings used throughout the interface.
|
||||
branding:
|
||||
agent_name: "Hermes Agent" # Banner title, about display
|
||||
welcome: "Welcome! Type your message or /help for commands."
|
||||
goodbye: "Goodbye! ⚕" # Exit message
|
||||
response_label: " ⚕ Hermes " # Response box header label
|
||||
prompt_symbol: "❯ " # Input prompt symbol
|
||||
help_header: "(^_^)? Available Commands" # /help header text
|
||||
agent_name: "Hermes Agent" # (both) Banner title, about display
|
||||
welcome: "Welcome! Type your message or /help for commands." # (both)
|
||||
goodbye: "Goodbye! ⚕" # (both) Exit message
|
||||
response_label: " ⚕ Hermes " # (classic) Response box header label
|
||||
prompt_symbol: "❯ " # (both) Input prompt glyph
|
||||
help_header: "(^_^)? Available Commands" # (both) /help overlay title
|
||||
|
||||
# ── Tool Output ─────────────────────────────────────────────────────────────
|
||||
# Character used as the prefix for tool output lines.
|
||||
# Character used as the prefix for tool output lines. (both)
|
||||
# Default is "┊" (thin dotted vertical line). Some alternatives:
|
||||
# "╎" (light triple dash vertical)
|
||||
# "▏" (left one-eighth block)
|
||||
|
|
|
|||
21
flake.lock
generated
21
flake.lock
generated
|
|
@ -36,6 +36,26 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"npm-lockfile-fix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775903712,
|
||||
"narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=",
|
||||
"owner": "jeslie0",
|
||||
"repo": "npm-lockfile-fix",
|
||||
"rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jeslie0",
|
||||
"repo": "npm-lockfile-fix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-build-systems": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
|
|
@ -124,6 +144,7 @@
|
|||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"npm-lockfile-fix": "npm-lockfile-fix",
|
||||
"pyproject-build-systems": "pyproject-build-systems",
|
||||
"pyproject-nix": "pyproject-nix_2",
|
||||
"uv2nix": "uv2nix_2"
|
||||
|
|
|
|||
13
flake.nix
13
flake.nix
|
|
@ -19,11 +19,20 @@
|
|||
url = "github:pyproject-nix/build-system-pkgs";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
npm-lockfile-fix = {
|
||||
url = "github:jeslie0/npm-lockfile-fix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = inputs:
|
||||
outputs =
|
||||
inputs:
|
||||
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
imports = [
|
||||
./nix/packages.nix
|
||||
|
|
|
|||
|
|
@ -1579,7 +1579,20 @@ class BasePlatformAdapter(ABC):
|
|||
# session lifecycle and its cleanup races with the running task
|
||||
# (see PR #4926).
|
||||
cmd = event.get_command()
|
||||
if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background", "restart", "queue", "q"):
|
||||
if cmd in (
|
||||
"approve",
|
||||
"deny",
|
||||
"status",
|
||||
"agents",
|
||||
"tasks",
|
||||
"stop",
|
||||
"new",
|
||||
"reset",
|
||||
"background",
|
||||
"restart",
|
||||
"queue",
|
||||
"q",
|
||||
):
|
||||
logger.debug(
|
||||
"[%s] Command '/%s' bypassing active-session guard for %s",
|
||||
self.name, cmd, session_key,
|
||||
|
|
|
|||
|
|
@ -3029,6 +3029,10 @@ class GatewayRunner:
|
|||
return await self._handle_approve_command(event)
|
||||
return await self._handle_deny_command(event)
|
||||
|
||||
# /agents (/tasks alias) should be query-only and never interrupt.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "agents":
|
||||
return await self._handle_agents_command(event)
|
||||
|
||||
# /background must bypass the running-agent guard — it starts a
|
||||
# parallel task and must never interrupt the active conversation.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "background":
|
||||
|
|
@ -3136,6 +3140,9 @@ class GatewayRunner:
|
|||
if canonical == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
if canonical == "agents":
|
||||
return await self._handle_agents_command(event)
|
||||
|
||||
if canonical == "restart":
|
||||
return await self._handle_restart_command(event)
|
||||
|
||||
|
|
@ -4591,6 +4598,96 @@ class GatewayRunner:
|
|||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_agents_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /agents command - list active agents and running tasks."""
|
||||
from tools.process_registry import format_uptime_short, process_registry
|
||||
|
||||
now = time.time()
|
||||
current_session_key = self._session_key_for_source(event.source)
|
||||
|
||||
running_agents: dict = getattr(self, "_running_agents", {}) or {}
|
||||
running_started: dict = getattr(self, "_running_agents_ts", {}) or {}
|
||||
|
||||
agent_rows: list[dict] = []
|
||||
for session_key, agent in running_agents.items():
|
||||
started = float(running_started.get(session_key, now))
|
||||
elapsed = max(0, int(now - started))
|
||||
is_pending = agent is _AGENT_PENDING_SENTINEL
|
||||
agent_rows.append(
|
||||
{
|
||||
"session_key": session_key,
|
||||
"elapsed": elapsed,
|
||||
"state": "starting" if is_pending else "running",
|
||||
"session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""),
|
||||
"model": "" if is_pending else str(getattr(agent, "model", "") or ""),
|
||||
}
|
||||
)
|
||||
|
||||
agent_rows.sort(key=lambda row: row["elapsed"], reverse=True)
|
||||
|
||||
running_processes: list[dict] = []
|
||||
try:
|
||||
running_processes = [
|
||||
p for p in process_registry.list_sessions()
|
||||
if p.get("status") == "running"
|
||||
]
|
||||
except Exception:
|
||||
running_processes = []
|
||||
|
||||
background_tasks = [
|
||||
t for t in (getattr(self, "_background_tasks", set()) or set())
|
||||
if hasattr(t, "done") and not t.done()
|
||||
]
|
||||
|
||||
lines = [
|
||||
"🤖 **Active Agents & Tasks**",
|
||||
"",
|
||||
f"**Active agents:** {len(agent_rows)}",
|
||||
]
|
||||
|
||||
if agent_rows:
|
||||
for idx, row in enumerate(agent_rows[:12], 1):
|
||||
current = " · this chat" if row["session_key"] == current_session_key else ""
|
||||
sid = f" · `{row['session_id']}`" if row["session_id"] else ""
|
||||
model = f" · `{row['model']}`" if row["model"] else ""
|
||||
lines.append(
|
||||
f"{idx}. `{row['session_key']}` · {row['state']} · "
|
||||
f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}"
|
||||
)
|
||||
if len(agent_rows) > 12:
|
||||
lines.append(f"... and {len(agent_rows) - 12} more")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"**Running background processes:** {len(running_processes)}",
|
||||
]
|
||||
)
|
||||
if running_processes:
|
||||
for proc in running_processes[:12]:
|
||||
cmd = " ".join(str(proc.get("command", "")).split())
|
||||
if len(cmd) > 90:
|
||||
cmd = cmd[:87] + "..."
|
||||
lines.append(
|
||||
f"- `{proc.get('session_id', '?')}` · "
|
||||
f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`"
|
||||
)
|
||||
if len(running_processes) > 12:
|
||||
lines.append(f"... and {len(running_processes) - 12} more")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"**Gateway async jobs:** {len(background_tasks)}",
|
||||
]
|
||||
)
|
||||
|
||||
if not agent_rows and not running_processes and not background_tasks:
|
||||
lines.append("")
|
||||
lines.append("No active agents or running tasks.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_stop_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /stop command - interrupt a running agent.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ CLI tools that ship with the platform (or are commonly installed).
|
|||
|
||||
Platform support:
|
||||
macOS — osascript (always available), pngpaste (if installed)
|
||||
Windows — PowerShell via .NET System.Windows.Forms.Clipboard
|
||||
WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard
|
||||
Windows — PowerShell via WinForms, Get-Clipboard, file-drop fallback
|
||||
WSL2 — powershell.exe via WinForms, Get-Clipboard, file-drop fallback
|
||||
Linux — wl-paste (Wayland), xclip (X11)
|
||||
"""
|
||||
|
||||
|
|
@ -46,10 +46,11 @@ def has_clipboard_image() -> bool:
|
|||
return _macos_has_image()
|
||||
if sys.platform == "win32":
|
||||
return _windows_has_image()
|
||||
if _is_wsl():
|
||||
return _wsl_has_image()
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
return _wayland_has_image()
|
||||
# Match _linux_save fallthrough order: WSL → Wayland → X11
|
||||
if _is_wsl() and _wsl_has_image():
|
||||
return True
|
||||
if os.environ.get("WAYLAND_DISPLAY") and _wayland_has_image():
|
||||
return True
|
||||
return _xclip_has_image()
|
||||
|
||||
|
||||
|
|
@ -135,6 +136,114 @@ _PS_EXTRACT_IMAGE = (
|
|||
"[System.Convert]::ToBase64String($ms.ToArray())"
|
||||
)
|
||||
|
||||
_PS_CHECK_IMAGE_GET_CLIPBOARD = (
|
||||
"try { "
|
||||
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
|
||||
"if ($null -ne $img) { 'True' } else { 'False' }"
|
||||
"} catch { 'False' }"
|
||||
)
|
||||
|
||||
_PS_EXTRACT_IMAGE_GET_CLIPBOARD = (
|
||||
"try { "
|
||||
"Add-Type -AssemblyName System.Drawing;"
|
||||
"Add-Type -AssemblyName PresentationCore;"
|
||||
"Add-Type -AssemblyName WindowsBase;"
|
||||
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
|
||||
"if ($null -eq $img) { exit 1 }"
|
||||
"$ms = New-Object System.IO.MemoryStream;"
|
||||
"if ($img -is [System.Drawing.Image]) {"
|
||||
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)"
|
||||
"} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {"
|
||||
"$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;"
|
||||
"$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));"
|
||||
"$enc.Save($ms)"
|
||||
"} else { exit 2 }"
|
||||
"[System.Convert]::ToBase64String($ms.ToArray())"
|
||||
"} catch { exit 1 }"
|
||||
)
|
||||
|
||||
_FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'"
|
||||
|
||||
_PS_CHECK_FILEDROP_IMAGE = (
|
||||
"try { "
|
||||
"$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;"
|
||||
f"$exts = @({_FILEDROP_IMAGE_EXTS});"
|
||||
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
|
||||
"if ($null -ne $hit) { 'True' } else { 'False' }"
|
||||
"} catch { 'False' }"
|
||||
)
|
||||
|
||||
_PS_EXTRACT_FILEDROP_IMAGE = (
|
||||
"try { "
|
||||
"$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;"
|
||||
f"$exts = @({_FILEDROP_IMAGE_EXTS});"
|
||||
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
|
||||
"if ($null -eq $hit) { exit 1 }"
|
||||
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))"
|
||||
"} catch { exit 1 }"
|
||||
)
|
||||
|
||||
_POWERSHELL_HAS_IMAGE_SCRIPTS = (
|
||||
_PS_CHECK_IMAGE,
|
||||
_PS_CHECK_IMAGE_GET_CLIPBOARD,
|
||||
_PS_CHECK_FILEDROP_IMAGE,
|
||||
)
|
||||
|
||||
_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = (
|
||||
_PS_EXTRACT_IMAGE,
|
||||
_PS_EXTRACT_IMAGE_GET_CLIPBOARD,
|
||||
_PS_EXTRACT_FILEDROP_IMAGE,
|
||||
)
|
||||
|
||||
|
||||
def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[exe, "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _write_base64_image(dest: Path, b64_data: str) -> bool:
|
||||
image_bytes = base64.b64decode(b64_data, validate=True)
|
||||
dest.write_bytes(image_bytes)
|
||||
return dest.exists() and dest.stat().st_size > 0
|
||||
|
||||
|
||||
def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool:
|
||||
for script in _POWERSHELL_HAS_IMAGE_SCRIPTS:
|
||||
try:
|
||||
r = _run_powershell(exe, script, timeout=timeout)
|
||||
if r.returncode == 0 and "True" in r.stdout:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.debug("%s not found — clipboard unavailable", exe)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("%s clipboard image check failed: %s", label, e)
|
||||
return False
|
||||
|
||||
|
||||
def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool:
|
||||
for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS:
|
||||
try:
|
||||
r = _run_powershell(exe, script, timeout=timeout)
|
||||
if r.returncode != 0:
|
||||
continue
|
||||
|
||||
b64_data = r.stdout.strip()
|
||||
if not b64_data:
|
||||
continue
|
||||
|
||||
if _write_base64_image(dest, b64_data):
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.debug("%s not found — clipboard unavailable", exe)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("%s clipboard image extraction failed: %s", label, e)
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Native Windows ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -175,15 +284,7 @@ def _windows_has_image() -> bool:
|
|||
ps = _get_ps_exe()
|
||||
if ps is None:
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[ps, "-NoProfile", "-NonInteractive", "-Command", _PS_CHECK_IMAGE],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return r.returncode == 0 and "True" in r.stdout
|
||||
except Exception as e:
|
||||
logger.debug("Windows clipboard image check failed: %s", e)
|
||||
return False
|
||||
return _powershell_has_image(ps, timeout=5, label="Windows")
|
||||
|
||||
|
||||
def _windows_save(dest: Path) -> bool:
|
||||
|
|
@ -192,26 +293,7 @@ def _windows_save(dest: Path) -> bool:
|
|||
if ps is None:
|
||||
logger.debug("No PowerShell found — Windows clipboard image paste unavailable")
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[ps, "-NoProfile", "-NonInteractive", "-Command", _PS_EXTRACT_IMAGE],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False
|
||||
|
||||
b64_data = r.stdout.strip()
|
||||
if not b64_data:
|
||||
return False
|
||||
|
||||
png_bytes = base64.b64decode(b64_data)
|
||||
dest.write_bytes(png_bytes)
|
||||
return dest.exists() and dest.stat().st_size > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Windows clipboard image extraction failed: %s", e)
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
return _powershell_save_image(ps, dest, timeout=15, label="Windows")
|
||||
|
||||
|
||||
# ── Linux ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -235,45 +317,12 @@ def _linux_save(dest: Path) -> bool:
|
|||
|
||||
def _wsl_has_image() -> bool:
|
||||
"""Check if Windows clipboard has an image (via powershell.exe)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
_PS_CHECK_IMAGE],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
return r.returncode == 0 and "True" in r.stdout
|
||||
except FileNotFoundError:
|
||||
logger.debug("powershell.exe not found — WSL clipboard unavailable")
|
||||
except Exception as e:
|
||||
logger.debug("WSL clipboard check failed: %s", e)
|
||||
return False
|
||||
return _powershell_has_image("powershell.exe", timeout=8, label="WSL")
|
||||
|
||||
|
||||
def _wsl_save(dest: Path) -> bool:
|
||||
"""Extract clipboard image via powershell.exe → base64 → decode to PNG."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
_PS_EXTRACT_IMAGE],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False
|
||||
|
||||
b64_data = r.stdout.strip()
|
||||
if not b64_data:
|
||||
return False
|
||||
|
||||
png_bytes = base64.b64decode(b64_data)
|
||||
dest.write_bytes(png_bytes)
|
||||
return dest.exists() and dest.stat().st_size > 0
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("powershell.exe not found — WSL clipboard unavailable")
|
||||
except Exception as e:
|
||||
logger.debug("WSL clipboard extraction failed: %s", e)
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL")
|
||||
|
||||
|
||||
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
aliases=("bg",), args_hint="<prompt>"),
|
||||
CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session",
|
||||
args_hint="<question>"),
|
||||
CommandDef("agents", "Show active agents and running tasks", "Session",
|
||||
aliases=("tasks",)),
|
||||
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
||||
aliases=("q",), args_hint="<prompt>"),
|
||||
CommandDef("status", "Show session info", "Session"),
|
||||
|
|
@ -99,7 +101,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
# Configuration
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"),
|
||||
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"),
|
||||
CommandDef("provider", "Show available providers and current provider",
|
||||
"Configuration"),
|
||||
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info"),
|
||||
|
|
@ -120,7 +122,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
args_hint="[normal|fast|status]",
|
||||
subcommands=("normal", "fast", "status", "on", "off")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
args_hint="[name]"),
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||||
|
||||
|
|
@ -155,7 +157,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
args_hint="[days]"),
|
||||
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
||||
cli_only=True, aliases=("gateway",)),
|
||||
CommandDef("paste", "Check clipboard for an image and attach it", "Info",
|
||||
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
|
||||
cli_only=True, args_hint="[number]"),
|
||||
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
|
||||
cli_only=True),
|
||||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||
cli_only=True, args_hint="<path>"),
|
||||
|
|
@ -1044,6 +1048,51 @@ class SlashCommandCompleter(Completer):
|
|||
display_meta=f"{fp} {meta}" if meta else fp,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _skin_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /skin from available skins."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import list_skins
|
||||
for s in list_skins():
|
||||
name = s["name"]
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=s.get("description", "") or s.get("source", ""),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _personality_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /personality from configured personalities."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
personalities = load_config().get("agent", {}).get("personalities", {})
|
||||
if "none".startswith(sub_lower) and "none" != sub_lower:
|
||||
yield Completion(
|
||||
"none",
|
||||
start_position=-len(sub_text),
|
||||
display="none",
|
||||
display_meta="clear personality overlay",
|
||||
)
|
||||
for name, prompt in personalities.items():
|
||||
if name.startswith(sub_lower) and name != sub_lower:
|
||||
if isinstance(prompt, dict):
|
||||
meta = prompt.get("description") or prompt.get("system_prompt", "")[:50]
|
||||
else:
|
||||
meta = str(prompt)[:50]
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(sub_text),
|
||||
display=name,
|
||||
display_meta=meta,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _model_completions(self, sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /model from config aliases + built-in aliases."""
|
||||
seen = set()
|
||||
|
|
@ -1098,10 +1147,17 @@ class SlashCommandCompleter(Completer):
|
|||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# Dynamic model alias completions for /model
|
||||
if " " not in sub_text and base_cmd == "/model":
|
||||
yield from self._model_completions(sub_text, sub_lower)
|
||||
return
|
||||
# Dynamic completions for commands with runtime lists
|
||||
if " " not in sub_text:
|
||||
if base_cmd == "/model":
|
||||
yield from self._model_completions(sub_text, sub_lower)
|
||||
return
|
||||
if base_cmd == "/skin":
|
||||
yield from self._skin_completions(sub_text, sub_lower)
|
||||
return
|
||||
if base_cmd == "/personality":
|
||||
yield from self._personality_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||
|
|
|
|||
2679
hermes_cli/main.py
2679
hermes_cli/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -692,12 +692,12 @@ def switch_model(
|
|||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
validation = {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
"recognized": False,
|
||||
"message": None,
|
||||
"message": f"Could not validate `{new_model}`: {e}",
|
||||
}
|
||||
|
||||
if not validation.get("accepted"):
|
||||
|
|
|
|||
|
|
@ -2050,8 +2050,8 @@ def validate_requested_model(
|
|||
)
|
||||
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
"recognized": False,
|
||||
"message": message,
|
||||
}
|
||||
|
|
@ -2064,8 +2064,8 @@ def validate_requested_model(
|
|||
message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`"
|
||||
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
"recognized": False,
|
||||
"message": message,
|
||||
}
|
||||
|
|
@ -2099,12 +2099,11 @@ def validate_requested_model(
|
|||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
"recognized": False,
|
||||
"message": (
|
||||
f"Note: `{requested}` was not found in the OpenAI Codex model listing. "
|
||||
f"It may still work if your account has access to it."
|
||||
f"Model `{requested}` was not found in the OpenAI Codex model listing."
|
||||
f"{suggestion_text}"
|
||||
),
|
||||
}
|
||||
|
|
@ -2143,16 +2142,15 @@ def validate_requested_model(
|
|||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": False,
|
||||
"message": (
|
||||
f"Note: `{requested}` was not found in this provider's model listing. "
|
||||
f"It may still work if your plan supports it."
|
||||
f"{suggestion_text}"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
"recognized": False,
|
||||
"message": (
|
||||
f"Model `{requested}` was not found in this provider's model listing."
|
||||
f"{suggestion_text}"
|
||||
),
|
||||
}
|
||||
|
||||
# api_models is None — couldn't reach API. Accept and persist,
|
||||
# but warn so typos don't silently break things.
|
||||
|
|
@ -2194,8 +2192,8 @@ def validate_requested_model(
|
|||
|
||||
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
"recognized": False,
|
||||
"message": (
|
||||
f"Could not reach the {provider_label} API to validate `{requested}`. "
|
||||
|
|
|
|||
|
|
@ -515,6 +515,90 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
|
|||
c.print()
|
||||
|
||||
|
||||
def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> dict:
|
||||
"""Paginated hub browse for programmatic callers (e.g. TUI gateway).
|
||||
|
||||
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
|
||||
"""
|
||||
from tools.skills_hub import GitHubAuth, create_source_router
|
||||
|
||||
page_size = max(1, min(page_size, 100))
|
||||
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
|
||||
"claude-marketplace": 50, "lobehub": 50}
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
all_results: list = []
|
||||
for src in sources:
|
||||
sid = src.source_id()
|
||||
if source != "all" and sid != source and sid != "official":
|
||||
continue
|
||||
try:
|
||||
limit = _PER_SOURCE_LIMIT.get(sid, 50)
|
||||
all_results.extend(src.search("", limit=limit))
|
||||
except Exception:
|
||||
continue
|
||||
if not all_results:
|
||||
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
|
||||
seen: dict = {}
|
||||
for r in all_results:
|
||||
rank = _TRUST_RANK.get(r.trust_level, 0)
|
||||
if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0):
|
||||
seen[r.name] = r
|
||||
deduped = list(seen.values())
|
||||
deduped.sort(key=lambda r: (-_TRUST_RANK.get(r.trust_level, 0), r.source != "official", r.name.lower()))
|
||||
total = len(deduped)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
page = max(1, min(page, total_pages))
|
||||
start = (page - 1) * page_size
|
||||
page_items = deduped[start : min(start + page_size, total)]
|
||||
return {
|
||||
"items": [{"name": r.name, "description": r.description, "source": r.source,
|
||||
"trust": r.trust_level} for r in page_items],
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def inspect_skill(identifier: str) -> Optional[dict]:
|
||||
"""Skill metadata (+ SKILL.md preview) for programmatic callers."""
|
||||
from tools.skills_hub import GitHubAuth, create_source_router
|
||||
|
||||
class _Q:
|
||||
def print(self, *a, **k):
|
||||
pass
|
||||
|
||||
c = _Q()
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
ident = identifier
|
||||
if "/" not in ident:
|
||||
ident = _resolve_short_name(ident, sources, c)
|
||||
if not ident:
|
||||
return None
|
||||
meta, bundle, _ = _resolve_source_meta_and_bundle(ident, sources)
|
||||
if not meta:
|
||||
return None
|
||||
out: dict = {
|
||||
"name": meta.name,
|
||||
"description": meta.description,
|
||||
"source": meta.source,
|
||||
"identifier": meta.identifier,
|
||||
"tags": list(meta.tags) if meta.tags else [],
|
||||
}
|
||||
if bundle and "SKILL.md" in bundle.files:
|
||||
content = bundle.files["SKILL.md"]
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8", errors="replace")
|
||||
lines = content.split("\n")
|
||||
preview = "\n".join(lines[:50])
|
||||
if len(lines) > 50:
|
||||
preview += f"\n\n... ({len(lines) - 50} more lines)"
|
||||
out["skill_md_preview"] = preview
|
||||
return out
|
||||
|
||||
|
||||
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
|
||||
"""List installed skills, distinguishing hub, builtin, and local skills."""
|
||||
from tools.skills_hub import HubLockFile, ensure_hub_dirs
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
|||
banner_dim: "#B8860B" # Dim/muted text (separators, labels)
|
||||
banner_text: "#FFF8DC" # Body text (tool names, skill names)
|
||||
ui_accent: "#FFBF00" # General UI accent
|
||||
ui_label: "#4dd0e1" # UI labels
|
||||
ui_label: "#DAA520" # UI labels (warm gold; teal clashed w/ default banner gold)
|
||||
ui_ok: "#4caf50" # Success indicators
|
||||
ui_error: "#ef5350" # Error indicators
|
||||
ui_warn: "#ffa726" # Warning indicators
|
||||
|
|
@ -163,7 +163,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
|
|||
"banner_dim": "#B8860B",
|
||||
"banner_text": "#FFF8DC",
|
||||
"ui_accent": "#FFBF00",
|
||||
"ui_label": "#4dd0e1",
|
||||
"ui_label": "#DAA520",
|
||||
"ui_ok": "#4caf50",
|
||||
"ui_error": "#ef5350",
|
||||
"ui_warn": "#ffa726",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ def get_hermes_home() -> Path:
|
|||
Reads HERMES_HOME env var, falls back to ~/.hermes.
|
||||
This is the single source of truth — all other copies should import this.
|
||||
"""
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
val = os.environ.get("HERMES_HOME", "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
|
||||
def get_default_hermes_root() -> Path:
|
||||
|
|
|
|||
|
|
@ -103,6 +103,28 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
|||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify bundled TUI is present and compiled
|
||||
bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } ''
|
||||
set -e
|
||||
echo "=== Checking bundled TUI ==="
|
||||
test -d ${hermes-agent}/ui-tui || (echo "FAIL: ui-tui directory missing"; exit 1)
|
||||
echo "PASS: ui-tui directory exists"
|
||||
|
||||
test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1)
|
||||
echo "PASS: compiled entry.js present"
|
||||
|
||||
test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1)
|
||||
echo "PASS: node_modules present"
|
||||
|
||||
grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \
|
||||
(echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1)
|
||||
echo "PASS: HERMES_TUI_DIR set in wrapper"
|
||||
|
||||
echo "=== All bundled TUI checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify HERMES_MANAGED guard works on all mutation commands
|
||||
managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
|
||||
set -e
|
||||
|
|
|
|||
|
|
@ -1,49 +1,26 @@
|
|||
# nix/devShell.nix — Fast dev shell with stamp-file optimization
|
||||
# nix/devShell.nix — Dev shell that delegates setup to each package
|
||||
#
|
||||
# Each package in inputsFrom exposes passthru.devShellHook — a bash snippet
|
||||
# with stamp-checked setup logic. This file collects and runs them all.
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, ... }:
|
||||
perSystem = { pkgs, system, ... }:
|
||||
let
|
||||
python = pkgs.python311;
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
hermes-tui = inputs.self.packages.${system}.tui;
|
||||
packages = [ hermes-agent hermes-tui ];
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = packages;
|
||||
packages = with pkgs; [
|
||||
python uv nodejs_20 ripgrep git openssh ffmpeg
|
||||
python311 uv nodejs_22 ripgrep git openssh ffmpeg
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
shellHook = let
|
||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
||||
in ''
|
||||
echo "Hermes Agent dev shell"
|
||||
|
||||
# Composite stamp: changes when nix python or uv change
|
||||
STAMP_VALUE="${python}:${pkgs.uv}"
|
||||
STAMP_FILE=".venv/.nix-stamp"
|
||||
|
||||
# Create venv if missing
|
||||
if [ ! -d .venv ]; then
|
||||
echo "Creating Python 3.11 venv..."
|
||||
uv venv .venv --python ${python}/bin/python3
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
|
||||
# Only install if stamp is stale or missing
|
||||
if [ ! -f "$STAMP_FILE" ] || [ "$(cat "$STAMP_FILE")" != "$STAMP_VALUE" ]; then
|
||||
echo "Installing Python dependencies..."
|
||||
uv pip install -e ".[all]"
|
||||
if [ -d mini-swe-agent ]; then
|
||||
uv pip install -e ./mini-swe-agent 2>/dev/null || true
|
||||
fi
|
||||
if [ -d tinker-atropos ]; then
|
||||
uv pip install -e ./tinker-atropos 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install npm deps
|
||||
if [ -f package.json ] && [ ! -d node_modules ]; then
|
||||
echo "Installing npm dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
echo "$STAMP_VALUE" > "$STAMP_FILE"
|
||||
fi
|
||||
|
||||
${combined}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
};
|
||||
|
|
|
|||
112
nix/packages.nix
112
nix/packages.nix
|
|
@ -1,54 +1,108 @@
|
|||
# nix/packages.nix — Hermes Agent package built with uv2nix
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, ... }:
|
||||
{ inputs, ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, inputs', ... }:
|
||||
let
|
||||
hermesVenv = pkgs.callPackage ./python.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
hermesTui = pkgs.callPackage ./tui.nix {
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
};
|
||||
|
||||
# Import bundled skills, excluding runtime caches
|
||||
bundledSkills = pkgs.lib.cleanSourceWith {
|
||||
src = ../skills;
|
||||
filter = path: _type:
|
||||
!(pkgs.lib.hasInfix "/index-cache/" path);
|
||||
filter = path: _type: !(pkgs.lib.hasInfix "/index-cache/" path);
|
||||
};
|
||||
|
||||
runtimeDeps = with pkgs; [
|
||||
nodejs_20 ripgrep git openssh ffmpeg tirith
|
||||
nodejs_22
|
||||
ripgrep
|
||||
git
|
||||
openssh
|
||||
ffmpeg
|
||||
tirith
|
||||
];
|
||||
|
||||
runtimePath = pkgs.lib.makeBinPath runtimeDeps;
|
||||
in {
|
||||
packages.default = pkgs.stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
# Lockfile hashes for dev shell stamps
|
||||
pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml);
|
||||
uvLockHash =
|
||||
if builtins.pathExists ../uv.lock then
|
||||
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
|
||||
else
|
||||
"none";
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
default = pkgs.stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
${pkgs.lib.concatMapStringsSep "\n" (name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--suffix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills
|
||||
'') [ "hermes" "hermes-agent" "hermes-acp" ]}
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
# copy pre-built TUI (same layout as dev: ui-tui/dist/ + node_modules/)
|
||||
mkdir -p $out/ui-tui
|
||||
cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AI agent with advanced tool-calling capabilities";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
mainProgram = "hermes";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
${pkgs.lib.concatMapStringsSep "\n"
|
||||
(name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--suffix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3
|
||||
'')
|
||||
[
|
||||
"hermes"
|
||||
"hermes-agent"
|
||||
"hermes-acp"
|
||||
]
|
||||
}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru.devShellHook = ''
|
||||
STAMP=".nix-stamps/hermes-agent"
|
||||
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "hermes-agent: installing Python dependencies..."
|
||||
uv venv .venv --python ${pkgs.python311}/bin/python3 2>/dev/null || true
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all]"
|
||||
[ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true
|
||||
[ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true
|
||||
mkdir -p .nix-stamps
|
||||
echo "$STAMP_VALUE" > "$STAMP"
|
||||
else
|
||||
source .venv/bin/activate
|
||||
export HERMES_PYTHON=${hermesVenv}/bin/python3
|
||||
fi
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AI agent with advanced tool-calling capabilities";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
mainProgram = "hermes";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
tui = hermesTui;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
82
nix/tui.nix
Normal file
82
nix/tui.nix
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
||||
{ pkgs, npm-lockfile-fix, ... }:
|
||||
let
|
||||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-zsUPmbC6oMUO10EhS3ptvDjwlfpCSEmrkjyeORw7fac=";
|
||||
};
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
|
||||
version = packageJson.version;
|
||||
|
||||
npmLockHash = builtins.hashString "sha256" (builtins.readFile ../ui-tui/package-lock.json);
|
||||
in
|
||||
pkgs.buildNpmPackage {
|
||||
pname = "hermes-tui";
|
||||
inherit src npmDeps version;
|
||||
|
||||
doCheck = false;
|
||||
|
||||
postPatch = ''
|
||||
# fetchNpmDeps strips the trailing newline; match it so the diff passes
|
||||
sed -i -z 's/\n$//' package-lock.json
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/hermes-tui
|
||||
|
||||
cp -r dist $out/lib/hermes-tui/dist
|
||||
|
||||
# runtime node_modules
|
||||
cp -r node_modules $out/lib/hermes-tui/node_modules
|
||||
|
||||
# @hermes/ink is a file: dependency, we need to copy it in fr
|
||||
rm -f $out/lib/hermes-tui/node_modules/@hermes/ink
|
||||
cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink
|
||||
|
||||
# package.json needed for "type": "module" resolution
|
||||
cp package.json $out/lib/hermes-tui/
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [
|
||||
(pkgs.writeShellScriptBin "update_tui_lockfile" ''
|
||||
set -euox pipefail
|
||||
|
||||
# get root of repo
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# cd into ui-tui and reinstall
|
||||
cd "$REPO_ROOT/ui-tui"
|
||||
rm -rf node_modules/
|
||||
npm cache clean --force
|
||||
CI=true npm install # ci env var to suppress annoying unicode install banner lag
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
NIX_FILE="$REPO_ROOT/nix/tui.nix"
|
||||
# compute the new hash
|
||||
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
|
||||
NIX_OUTPUT=$(nix build .#tui 2>&1 || true)
|
||||
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
|
||||
echo got new hash $NEW_HASH
|
||||
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
|
||||
nix build .#tui
|
||||
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
|
||||
'')
|
||||
];
|
||||
|
||||
passthru.devShellHook = ''
|
||||
STAMP=".nix-stamps/hermes-tui"
|
||||
STAMP_VALUE="${npmLockHash}"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "hermes-tui: installing npm dependencies..."
|
||||
cd ui-tui && CI=true npm install --silent --no-fund --no-audit 2>/dev/null && cd ..
|
||||
mkdir -p .nix-stamps
|
||||
echo "$STAMP_VALUE" > "$STAMP"
|
||||
fi
|
||||
'';
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
|
|||
hermes_cli = ["web_dist/**/*"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
|
|
|||
16
run_agent.py
16
run_agent.py
|
|
@ -5894,6 +5894,7 @@ class AIAgent:
|
|||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._emit_status("🔄 Reconnected — resuming…")
|
||||
continue
|
||||
self._emit_status(
|
||||
"❌ Connection to provider failed after "
|
||||
|
|
@ -7002,7 +7003,7 @@ class AIAgent:
|
|||
# (gateway, batch, quiet) still get reasoning.
|
||||
# Any reasoning that wasn't shown during streaming is caught by the
|
||||
# CLI post-response display fallback (cli.py _reasoning_shown_this_turn).
|
||||
if not self.stream_delta_callback:
|
||||
if not self.stream_delta_callback and not self._stream_callback:
|
||||
try:
|
||||
self.reasoning_callback(reasoning_text)
|
||||
except Exception:
|
||||
|
|
@ -10443,9 +10444,9 @@ class AIAgent:
|
|||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
if is_rate_limited:
|
||||
self._emit_status(f"⏱️ Rate limit reached. Waiting {wait_time}s before retry (attempt {retry_count + 1}/{max_retries})...")
|
||||
self._emit_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
|
||||
else:
|
||||
self._emit_status(f"⏳ Retrying in {wait_time}s (attempt {retry_count}/{max_retries})...")
|
||||
self._emit_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
|
||||
logger.warning(
|
||||
"Retrying API call in %ss (attempt %s/%s) %s error=%s",
|
||||
wait_time,
|
||||
|
|
@ -10861,7 +10862,14 @@ class AIAgent:
|
|||
elif self.quiet_mode:
|
||||
clean = self._strip_think_blocks(turn_content).strip()
|
||||
if clean:
|
||||
self._vprint(f" ┊ 💬 {clean}")
|
||||
relayed = False
|
||||
if (
|
||||
self.tool_progress_callback
|
||||
and getattr(self, "platform", "") == "tui"
|
||||
):
|
||||
relayed = True
|
||||
if not relayed:
|
||||
self._vprint(f" ┊ 💬 {clean}")
|
||||
|
||||
# Pop thinking-only prefill message(s) before appending
|
||||
# (tool-call path — same rationale as the final-response path).
|
||||
|
|
|
|||
|
|
@ -721,6 +721,20 @@ function Install-NodeDeps {
|
|||
}
|
||||
}
|
||||
|
||||
# Install TUI dependencies
|
||||
$tuiDir = "$InstallDir\ui-tui"
|
||||
if (Test-Path "$tuiDir\package.json") {
|
||||
Write-Info "Installing TUI dependencies..."
|
||||
Push-Location $tuiDir
|
||||
try {
|
||||
npm install --silent 2>&1 | Out-Null
|
||||
Write-Success "TUI dependencies installed"
|
||||
} catch {
|
||||
Write-Warn "TUI npm install failed (hermes --tui may not work)"
|
||||
}
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# Install WhatsApp bridge dependencies
|
||||
$bridgeDir = "$InstallDir\scripts\whatsapp-bridge"
|
||||
if (Test-Path "$bridgeDir\package.json") {
|
||||
|
|
|
|||
|
|
@ -1194,6 +1194,16 @@ install_node_deps() {
|
|||
log_success "Browser engine setup complete"
|
||||
fi
|
||||
|
||||
# Install TUI dependencies
|
||||
if [ -f "$INSTALL_DIR/ui-tui/package.json" ]; then
|
||||
log_info "Installing TUI dependencies..."
|
||||
cd "$INSTALL_DIR/ui-tui"
|
||||
npm install --silent 2>/dev/null || {
|
||||
log_warn "TUI npm install failed (hermes --tui may not work)"
|
||||
}
|
||||
log_success "TUI dependencies installed"
|
||||
fi
|
||||
|
||||
# Install WhatsApp bridge dependencies
|
||||
if [ -f "$INSTALL_DIR/scripts/whatsapp-bridge/package.json" ]; then
|
||||
log_info "Installing WhatsApp bridge dependencies..."
|
||||
|
|
|
|||
238
scripts/lib/node-bootstrap.sh
Normal file
238
scripts/lib/node-bootstrap.sh
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# scripts/lib/node-bootstrap.sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Sourceable helper: ensure Node.js >= MIN_VERSION is available for the TUI
|
||||
# (React + Ink), browser tools, and the WhatsApp bridge.
|
||||
#
|
||||
# Strategy (first hit wins — respects the user's existing tooling):
|
||||
# 1. modern `node` already on PATH
|
||||
# 2. ~/.hermes/node/ from a prior Hermes-managed install
|
||||
# 3. fnm, proto, nvm (in that order) if the user already uses a version manager
|
||||
# 4. Termux `pkg`, macOS Homebrew
|
||||
# 5. pinned nodejs.org tarball into ~/.hermes/node/ (always works, zero shell rc edits)
|
||||
#
|
||||
# Usage:
|
||||
# source scripts/lib/node-bootstrap.sh
|
||||
# ensure_node # returns 0 on success, non-zero on failure
|
||||
# if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi
|
||||
#
|
||||
# Env inputs (set before sourcing to override defaults):
|
||||
# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH
|
||||
# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install
|
||||
# HERMES_HOME (default: $HOME/.hermes)
|
||||
# ============================================================================
|
||||
|
||||
HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}"
|
||||
HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}"
|
||||
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||
HERMES_NODE_AVAILABLE=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging — prefer the host script's log_* helpers when present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_log() { declare -F log_info >/dev/null 2>&1 && log_info "$*" || printf '→ %s\n' "$*" >&2; }
|
||||
_nb_ok() { declare -F log_success >/dev/null 2>&1 && log_success "$*" || printf '✓ %s\n' "$*" >&2; }
|
||||
_nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || printf '⚠ %s\n' "$*" >&2; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform + version helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
_nb_node_major() {
|
||||
local v
|
||||
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
|
||||
}
|
||||
|
||||
_nb_have_modern_node() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
[ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version-manager paths — respect what the user already uses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_try_fnm() {
|
||||
command -v fnm >/dev/null 2>&1 || return 1
|
||||
_nb_log "fnm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
eval "$(fnm env 2>/dev/null)" || true
|
||||
fnm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
fnm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via fnm"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_proto() {
|
||||
command -v proto >/dev/null 2>&1 || return 1
|
||||
_nb_log "proto detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
proto install node "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via proto"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_nvm() {
|
||||
local nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh"
|
||||
[ -s "$nvm_sh" ] || return 1
|
||||
# shellcheck source=/dev/null
|
||||
\. "$nvm_sh" >/dev/null 2>&1 || return 1
|
||||
_nb_log "nvm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
nvm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
nvm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via nvm"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform package managers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_try_termux_pkg() {
|
||||
_nb_is_termux || return 1
|
||||
_nb_log "Installing Node.js via pkg..."
|
||||
pkg install -y nodejs >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed via pkg"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_brew() {
|
||||
[ "$(uname -s)" = "Darwin" ] || return 1
|
||||
command -v brew >/dev/null 2>&1 || return 1
|
||||
_nb_log "Installing Node via Homebrew..."
|
||||
brew install "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 \
|
||||
|| brew install node >/dev/null 2>&1 \
|
||||
|| return 1
|
||||
brew link --overwrite --force "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 || true
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed via Homebrew"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundled binary fallback — always works, no shell rc edits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_install_bundled_node() {
|
||||
local arch node_arch os_name node_os
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) node_arch="x64" ;;
|
||||
aarch64|arm64) node_arch="arm64" ;;
|
||||
armv7l) node_arch="armv7l" ;;
|
||||
*)
|
||||
_nb_warn "Unsupported arch ($arch) — install Node.js manually: https://nodejs.org/"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
os_name=$(uname -s)
|
||||
case "$os_name" in
|
||||
Linux*) node_os="linux" ;;
|
||||
Darwin*) node_os="darwin" ;;
|
||||
*)
|
||||
_nb_warn "Unsupported OS ($os_name) — install Node.js manually: https://nodejs.org/"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local index_url="https://nodejs.org/dist/latest-v${HERMES_NODE_TARGET_MAJOR}.x/"
|
||||
local tarball
|
||||
tarball=$(curl -fsSL "$index_url" \
|
||||
| grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \
|
||||
| head -1)
|
||||
if [ -z "$tarball" ]; then
|
||||
tarball=$(curl -fsSL "$index_url" \
|
||||
| grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \
|
||||
| head -1)
|
||||
fi
|
||||
if [ -z "$tarball" ]; then
|
||||
_nb_warn "Could not resolve Node $HERMES_NODE_TARGET_MAJOR binary for $node_os-$node_arch"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp -d)
|
||||
_nb_log "Downloading $tarball..."
|
||||
curl -fsSL "${index_url}${tarball}" -o "$tmp/$tarball" || {
|
||||
_nb_warn "Download failed"; rm -rf "$tmp"; return 1
|
||||
}
|
||||
|
||||
_nb_log "Extracting to $HERMES_HOME/node/..."
|
||||
if [[ "$tarball" == *.tar.xz ]]; then
|
||||
tar xf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||
else
|
||||
tar xzf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||
fi
|
||||
|
||||
local extracted
|
||||
extracted=$(find "$tmp" -maxdepth 1 -type d -name 'node-v*' 2>/dev/null | head -1)
|
||||
if [ ! -d "$extracted" ]; then
|
||||
_nb_warn "Extraction produced no node-v* directory"
|
||||
rm -rf "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
mkdir -p "$HERMES_HOME"
|
||||
rm -rf "$HERMES_HOME/node"
|
||||
mv "$extracted" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp"
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed to $HERMES_HOME/node/"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ensure_node() {
|
||||
HERMES_NODE_AVAILABLE=false
|
||||
|
||||
if _nb_have_modern_node; then
|
||||
_nb_ok "Node $(node --version) found"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -x "$HERMES_HOME/node/bin/node" ]; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
if _nb_have_modern_node; then
|
||||
_nb_ok "Node $(node --version) found (Hermes-managed)"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Version managers first — respect the user's existing setup.
|
||||
_nb_try_fnm && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_proto && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_nvm && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
|
||||
# Platform package managers.
|
||||
_nb_try_termux_pkg && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_brew && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
|
||||
# Last resort: pinned nodejs.org tarball.
|
||||
_nb_install_bundled_node && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
|
||||
_nb_warn "Node.js install failed — TUI and browser tools will be unavailable."
|
||||
_nb_warn "Install manually: https://nodejs.org/en/download/ (or: \`brew install node\`, \`fnm install $HERMES_NODE_TARGET_MAJOR\`, etc.)"
|
||||
return 1
|
||||
}
|
||||
71
tests/cli/test_cli_copy_command.py
Normal file
71
tests/cli/test_cli_copy_command.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Tests for CLI /copy command."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli() -> HermesCLI:
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.config = {}
|
||||
cli_obj.console = MagicMock()
|
||||
cli_obj.agent = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.session_id = "sess-copy-test"
|
||||
cli_obj._pending_input = MagicMock()
|
||||
cli_obj._app = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_copy_copies_latest_assistant_message():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "first"},
|
||||
{"role": "assistant", "content": "latest"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
result = cli_obj.process_command("/copy")
|
||||
|
||||
assert result is True
|
||||
mock_copy.assert_called_once_with("latest")
|
||||
|
||||
|
||||
def test_copy_with_index_uses_requested_assistant_message():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{"role": "assistant", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
cli_obj.process_command("/copy 1")
|
||||
|
||||
mock_copy.assert_called_once_with("one")
|
||||
|
||||
|
||||
def test_copy_strips_reasoning_blocks_before_copy():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<REASONING_SCRATCHPAD>internal</REASONING_SCRATCHPAD>\nVisible answer",
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
cli_obj.process_command("/copy")
|
||||
|
||||
mock_copy.assert_called_once_with("Visible answer")
|
||||
|
||||
|
||||
def test_copy_invalid_index_does_not_copy():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [{"role": "assistant", "content": "only"}]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy, patch("cli._cprint") as mock_print:
|
||||
cli_obj.process_command("/copy 99")
|
||||
|
||||
mock_copy.assert_not_called()
|
||||
assert any("Invalid response number" in str(call) for call in mock_print.call_args_list)
|
||||
|
|
@ -160,6 +160,30 @@ class TestCommandBypassActiveSession:
|
|||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:status" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_bypasses_guard(self):
|
||||
"""/agents must bypass so active-task queries don't interrupt runs."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/agents"))
|
||||
|
||||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:agents" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_alias_bypasses_guard(self):
|
||||
"""/tasks alias must bypass active-session guard too."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/tasks"))
|
||||
|
||||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:tasks" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_bypasses_guard(self):
|
||||
"""/background must bypass so it spawns a parallel task, not an interrupt."""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for gateway /status behavior and token persistence."""
|
||||
|
||||
from datetime import datetime
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
|
@ -111,6 +112,75 @@ async def test_status_command_includes_session_title_when_present():
|
|||
assert "**Title:** My titled session" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_command_reports_active_agents_and_processes(monkeypatch):
|
||||
session_key = build_session_key(_make_source())
|
||||
session_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
total_tokens=0,
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
running_agent = SimpleNamespace(
|
||||
session_id="sess-running",
|
||||
model="openrouter/test-model",
|
||||
interrupt=MagicMock(),
|
||||
get_activity_summary=lambda: {"seconds_since_activity": 0},
|
||||
)
|
||||
runner._running_agents[session_key] = running_agent
|
||||
runner._running_agents_ts = {session_key: time.time() - 8}
|
||||
runner._background_tasks = set()
|
||||
|
||||
class _FakeRegistry:
|
||||
def list_sessions(self):
|
||||
return [
|
||||
{
|
||||
"session_id": "proc-1",
|
||||
"status": "running",
|
||||
"uptime_seconds": 17,
|
||||
"command": "sleep 30",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("tools.process_registry.process_registry", _FakeRegistry())
|
||||
|
||||
result = await runner._handle_message(_make_event("/agents"))
|
||||
|
||||
assert "**Active agents:** 1" in result
|
||||
assert "**Running background processes:** 1" in result
|
||||
assert "proc-1" in result
|
||||
running_agent.interrupt.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_alias_routes_to_agents_command(monkeypatch):
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
total_tokens=0,
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
runner._background_tasks = set()
|
||||
|
||||
class _FakeRegistry:
|
||||
def list_sessions(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("tools.process_registry.process_registry", _FakeRegistry())
|
||||
|
||||
result = await runner._handle_message(_make_event("/tasks"))
|
||||
|
||||
assert "Active Agents & Tasks" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_persists_agent_token_counts(monkeypatch):
|
||||
import gateway.run as gateway_run
|
||||
|
|
|
|||
|
|
@ -106,6 +106,49 @@ class TestCmdUpdateBranchFallback:
|
|||
pull_cmds = [c for c in commands if "pull" in c]
|
||||
assert len(pull_cmds) == 0
|
||||
|
||||
@patch("shutil.which")
|
||||
@patch("subprocess.run")
|
||||
def test_update_refreshes_repo_and_tui_node_dependencies(
|
||||
self, mock_run, mock_which, mock_args
|
||||
):
|
||||
mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
cmd_update(mock_args)
|
||||
|
||||
npm_calls = [
|
||||
(call.args[0], call.kwargs.get("cwd"))
|
||||
for call in mock_run.call_args_list
|
||||
if call.args and call.args[0][0] == "/usr/bin/npm"
|
||||
]
|
||||
|
||||
assert npm_calls == [
|
||||
(
|
||||
[
|
||||
"/usr/bin/npm",
|
||||
"install",
|
||||
"--silent",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
],
|
||||
PROJECT_ROOT,
|
||||
),
|
||||
(
|
||||
[
|
||||
"/usr/bin/npm",
|
||||
"install",
|
||||
"--silent",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
],
|
||||
PROJECT_ROOT / "ui-tui",
|
||||
),
|
||||
]
|
||||
|
||||
def test_update_non_interactive_skips_migration_prompt(self, mock_args, capsys):
|
||||
"""When stdin/stdout aren't TTYs, config migration prompt is skipped."""
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ class TestResolveCommand:
|
|||
def test_canonical_name_resolves(self):
|
||||
assert resolve_command("help").name == "help"
|
||||
assert resolve_command("background").name == "background"
|
||||
assert resolve_command("copy").name == "copy"
|
||||
assert resolve_command("agents").name == "agents"
|
||||
|
||||
def test_alias_resolves_to_canonical(self):
|
||||
assert resolve_command("bg").name == "background"
|
||||
|
|
@ -102,6 +104,7 @@ class TestResolveCommand:
|
|||
assert resolve_command("gateway").name == "platforms"
|
||||
assert resolve_command("set-home").name == "sethome"
|
||||
assert resolve_command("reload_mcp").name == "reload-mcp"
|
||||
assert resolve_command("tasks").name == "agents"
|
||||
|
||||
def test_leading_slash_stripped(self):
|
||||
assert resolve_command("/help").name == "help"
|
||||
|
|
|
|||
|
|
@ -403,7 +403,8 @@ class TestValidateFormatChecks:
|
|||
|
||||
def test_no_slash_model_rejected_if_not_in_api(self):
|
||||
result = _validate("gpt-5.4", api_models=["openai/gpt-5.4"])
|
||||
assert result["accepted"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
|
||||
|
|
@ -429,10 +430,10 @@ class TestValidateApiFound:
|
|||
# -- validate — API not found ------------------------------------------------
|
||||
|
||||
class TestValidateApiNotFound:
|
||||
def test_model_not_in_api_accepted_with_warning(self):
|
||||
def test_model_not_in_api_rejected_with_guidance(self):
|
||||
result = _validate("anthropic/claude-nonexistent")
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_warning_includes_suggestions(self):
|
||||
|
|
@ -456,30 +457,29 @@ class TestValidateApiNotFound:
|
|||
assert "not found" in result["message"]
|
||||
|
||||
|
||||
# -- validate — API unreachable — accept and persist everything ----------------
|
||||
# -- validate — API unreachable — reject with guidance ----------------
|
||||
|
||||
class TestValidateApiFallback:
|
||||
def test_any_model_accepted_when_api_down(self):
|
||||
def test_any_model_rejected_when_api_down(self):
|
||||
result = _validate("anthropic/claude-opus-4.6", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
|
||||
def test_unknown_model_also_accepted_when_api_down(self):
|
||||
"""No hardcoded catalog gatekeeping — accept, persist, and warn."""
|
||||
def test_unknown_model_also_rejected_when_api_down(self):
|
||||
result = _validate("anthropic/claude-next-gen", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "could not reach" in result["message"].lower()
|
||||
|
||||
def test_zai_model_accepted_when_api_down(self):
|
||||
def test_zai_model_rejected_when_api_down(self):
|
||||
result = _validate("glm-5", provider="zai", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
|
||||
def test_unknown_provider_accepted_when_api_down(self):
|
||||
def test_unknown_provider_rejected_when_api_down(self):
|
||||
result = _validate("some-model", provider="totally-unknown", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
|
||||
def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
|
||||
with patch(
|
||||
|
|
@ -499,8 +499,8 @@ class TestValidateApiFallback:
|
|||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "http://localhost:8000/v1/models" in result["message"]
|
||||
assert "http://localhost:8000/v1" in result["message"]
|
||||
|
||||
|
|
|
|||
53
tests/hermes_cli/test_tui_npm_install.py
Normal file
53
tests/hermes_cli/test_tui_npm_install.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""_tui_need_npm_install: auto npm when lockfile ahead of node_modules."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod():
|
||||
import hermes_cli.main as m
|
||||
|
||||
return m
|
||||
|
||||
|
||||
def _touch_ink(root: Path) -> None:
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
ink.parent.mkdir(parents=True, exist_ok=True)
|
||||
ink.write_text("{}")
|
||||
|
||||
|
||||
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_need_install_when_lock_newer_than_marker(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
|
||||
os.utime(tmp_path / "package-lock.json", (200, 200))
|
||||
os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100))
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
|
||||
os.utime(tmp_path / "package-lock.json", (100, 100))
|
||||
os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200))
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
121
tests/hermes_cli/test_tui_resume_flow.py
Normal file
121
tests/hermes_cli/test_tui_resume_flow.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from argparse import Namespace
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _args(**overrides):
|
||||
base = {
|
||||
"continue_last": None,
|
||||
"resume": None,
|
||||
"tui": True,
|
||||
}
|
||||
base.update(overrides)
|
||||
return Namespace(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod(monkeypatch):
|
||||
import hermes_cli.main as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
|
||||
return mod
|
||||
|
||||
|
||||
def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
|
||||
calls = []
|
||||
captured = {}
|
||||
|
||||
def fake_resolve_last(source="cli"):
|
||||
calls.append(source)
|
||||
return "20260408_235959_a1b2c3" if source == "tui" else None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(continue_last=True))
|
||||
|
||||
assert calls == ["tui"]
|
||||
assert captured["resume"] == "20260408_235959_a1b2c3"
|
||||
|
||||
|
||||
def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, main_mod):
|
||||
calls = []
|
||||
captured = {}
|
||||
|
||||
def fake_resolve_last(source="cli"):
|
||||
calls.append(source)
|
||||
if source == "tui":
|
||||
return None
|
||||
if source == "cli":
|
||||
return "20260408_235959_d4e5f6"
|
||||
return None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(continue_last=True))
|
||||
|
||||
assert calls == ["tui", "cli"]
|
||||
assert captured["resume"] == "20260408_235959_d4e5f6"
|
||||
|
||||
|
||||
def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: "20260409_000000_aa11bb")
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(resume="my t0p session"))
|
||||
|
||||
assert captured["resume"] == "20260409_000000_aa11bb"
|
||||
|
||||
|
||||
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
class _FakeDB:
|
||||
def get_session(self, session_id):
|
||||
assert session_id == "20260409_000001_abc123"
|
||||
return {
|
||||
"message_count": 2,
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 6,
|
||||
"cache_read_tokens": 2,
|
||||
"cache_write_tokens": 2,
|
||||
"reasoning_tokens": 1,
|
||||
}
|
||||
|
||||
def get_session_title(self, _session_id):
|
||||
return "demo title"
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB()))
|
||||
|
||||
main_mod._print_tui_exit_summary("20260409_000001_abc123")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Resume this session with:" in out
|
||||
assert "hermes --tui --resume 20260409_000001_abc123" in out
|
||||
assert 'hermes --tui -c "demo title"' in out
|
||||
assert "Tokens: 21 (in 10, out 6, cache 4, reasoning 1)" in out
|
||||
440
tests/test_tui_gateway_server.py
Normal file
440
tests/test_tui_gateway_server.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
class _ChunkyStdout:
|
||||
def __init__(self):
|
||||
self.parts: list[str] = []
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
for ch in text:
|
||||
self.parts.append(ch)
|
||||
time.sleep(0.0001)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _BrokenStdout:
|
||||
def write(self, text: str) -> int:
|
||||
raise BrokenPipeError
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_write_json_serializes_concurrent_writes(monkeypatch):
|
||||
out = _ChunkyStdout()
|
||||
monkeypatch.setattr(server, "_real_stdout", out)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=server.write_json, args=({"seq": i, "text": "x" * 24},))
|
||||
for i in range(8)
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
lines = "".join(out.parts).splitlines()
|
||||
|
||||
assert len(lines) == 8
|
||||
assert {json.loads(line)["seq"] for line in lines} == set(range(8))
|
||||
|
||||
|
||||
def test_write_json_returns_false_on_broken_pipe(monkeypatch):
|
||||
monkeypatch.setattr(server, "_real_stdout", _BrokenStdout())
|
||||
|
||||
assert server.write_json({"ok": True}) is False
|
||||
|
||||
|
||||
def test_status_callback_emits_kind_and_text():
|
||||
with patch("tui_gateway.server._emit") as emit:
|
||||
cb = server._agent_cbs("sid")["status_callback"]
|
||||
cb("context_pressure", "85% to compaction")
|
||||
|
||||
emit.assert_called_once_with(
|
||||
"status.update",
|
||||
"sid",
|
||||
{"kind": "context_pressure", "text": "85% to compaction"},
|
||||
)
|
||||
|
||||
|
||||
def test_status_callback_accepts_single_message_argument():
|
||||
with patch("tui_gateway.server._emit") as emit:
|
||||
cb = server._agent_cbs("sid")["status_callback"]
|
||||
cb("thinking...")
|
||||
|
||||
emit.assert_called_once_with(
|
||||
"status.update",
|
||||
"sid",
|
||||
{"kind": "status", "text": "thinking..."},
|
||||
)
|
||||
|
||||
|
||||
def _session(agent=None, **extra):
|
||||
return {
|
||||
"agent": agent if agent is not None else types.SimpleNamespace(),
|
||||
"session_key": "session-key",
|
||||
"history": [],
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"running": False,
|
||||
"attached_images": [],
|
||||
"image_counter": 0,
|
||||
"cols": 80,
|
||||
"slash_worker": None,
|
||||
"show_reasoning": False,
|
||||
"tool_progress_mode": "all",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def test_config_set_yolo_toggles_session_scope():
|
||||
from tools.approval import clear_session, is_session_yolo_enabled
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
try:
|
||||
resp_on = server.handle_request({"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}})
|
||||
assert resp_on["result"]["value"] == "1"
|
||||
assert is_session_yolo_enabled("session-key") is True
|
||||
|
||||
resp_off = server.handle_request({"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}})
|
||||
assert resp_off["result"]["value"] == "0"
|
||||
assert is_session_yolo_enabled("session-key") is False
|
||||
finally:
|
||||
clear_session("session-key")
|
||||
server._sessions.clear()
|
||||
|
||||
|
||||
def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
server._enable_gateway_prompts()
|
||||
|
||||
assert server.os.environ["HERMES_GATEWAY_SESSION"] == "1"
|
||||
assert server.os.environ["HERMES_EXEC_ASK"] == "1"
|
||||
assert server.os.environ["HERMES_INTERACTIVE"] == "1"
|
||||
|
||||
|
||||
def test_setup_status_reports_provider_config(monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "setup.status", "params": {}})
|
||||
|
||||
assert resp["result"]["provider_configured"] is False
|
||||
|
||||
|
||||
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
agent = types.SimpleNamespace(reasoning_config=None)
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
resp_effort = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "low"}}
|
||||
)
|
||||
assert resp_effort["result"]["value"] == "low"
|
||||
assert agent.reasoning_config == {"enabled": True, "effort": "low"}
|
||||
|
||||
resp_show = server.handle_request(
|
||||
{"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "show"}}
|
||||
)
|
||||
assert resp_show["result"]["value"] == "show"
|
||||
assert server._sessions["sid"]["show_reasoning"] is True
|
||||
|
||||
|
||||
def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
agent = types.SimpleNamespace(verbose_logging=False)
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "verbose"
|
||||
assert server._sessions["sid"]["tool_progress_mode"] == "verbose"
|
||||
assert agent.verbose_logging is True
|
||||
|
||||
|
||||
def test_config_set_model_uses_live_switch_path(monkeypatch):
|
||||
server._sessions["sid"] = _session()
|
||||
seen = {}
|
||||
|
||||
def _fake_apply(sid, session, raw):
|
||||
seen["args"] = (sid, session["session_key"], raw)
|
||||
return {"value": "new/model", "warning": "catalog unreachable"}
|
||||
|
||||
monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "new/model"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "new/model"
|
||||
assert resp["result"]["warning"] == "catalog unreachable"
|
||||
assert seen["args"] == ("sid", "session-key", "new/model")
|
||||
|
||||
|
||||
def test_config_set_model_global_persists(monkeypatch):
|
||||
class _Agent:
|
||||
provider = "openrouter"
|
||||
model = "old/model"
|
||||
base_url = ""
|
||||
api_key = "sk-old"
|
||||
|
||||
def switch_model(self, **kwargs):
|
||||
return None
|
||||
|
||||
result = types.SimpleNamespace(
|
||||
success=True,
|
||||
new_model="anthropic/claude-sonnet-4.6",
|
||||
target_provider="anthropic",
|
||||
api_key="sk-new",
|
||||
base_url="https://api.anthropic.com",
|
||||
api_mode="anthropic_messages",
|
||||
warning_message="",
|
||||
)
|
||||
seen = {}
|
||||
saved = {}
|
||||
|
||||
def _switch_model(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return result
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch_model)
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6 --global"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6"
|
||||
assert seen["is_global"] is True
|
||||
assert saved["model"]["default"] == "anthropic/claude-sonnet-4.6"
|
||||
assert saved["model"]["provider"] == "anthropic"
|
||||
assert saved["model"]["base_url"] == "https://api.anthropic.com"
|
||||
|
||||
|
||||
def test_config_set_personality_rejects_unknown_name(monkeypatch):
|
||||
monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"key": "personality", "value": "bogus"}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "Unknown personality" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_config_set_personality_resets_history_and_returns_info(monkeypatch):
|
||||
session = _session(agent=types.SimpleNamespace(), history=[{"role": "user", "text": "hi"}], history_version=4)
|
||||
new_agent = types.SimpleNamespace(model="x")
|
||||
emits = []
|
||||
|
||||
server._sessions["sid"] = session
|
||||
monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."})
|
||||
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: new_agent)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")})
|
||||
monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args))
|
||||
monkeypatch.setattr(server, "_write_config_key", lambda path, value: None)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "personality", "value": "helpful"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["history_reset"] is True
|
||||
assert resp["result"]["info"] == {"model": "x"}
|
||||
assert session["history"] == []
|
||||
assert session["history_version"] == 5
|
||||
assert ("session.info", "sid", {"model": "x"}) in emits
|
||||
|
||||
|
||||
def test_session_compress_uses_compress_helper(monkeypatch):
|
||||
agent = types.SimpleNamespace()
|
||||
server._sessions["sid"] = _session(agent=agent)
|
||||
|
||||
monkeypatch.setattr(server, "_compress_session_history", lambda session, focus_topic=None: (2, {"total": 42}))
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
|
||||
|
||||
with patch("tui_gateway.server._emit") as emit:
|
||||
resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}})
|
||||
|
||||
assert resp["result"]["removed"] == 2
|
||||
assert resp["result"]["usage"]["total"] == 42
|
||||
emit.assert_called_once_with("session.info", "sid", {"model": "x"})
|
||||
|
||||
|
||||
def test_prompt_submit_sets_approval_session_key(monkeypatch):
|
||||
from tools.approval import get_current_session_key
|
||||
|
||||
captured = {}
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
captured["session_key"] = get_current_session_key(default="")
|
||||
return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
self._target = target
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "ping"}})
|
||||
|
||||
assert resp["result"]["status"] == "streaming"
|
||||
assert captured["session_key"] == "session-key"
|
||||
|
||||
|
||||
def test_prompt_submit_expands_context_refs(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class _Agent:
|
||||
model = "test/model"
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
captured["prompt"] = prompt
|
||||
return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]}
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target=None, daemon=None):
|
||||
self._target = target
|
||||
|
||||
def start(self):
|
||||
self._target()
|
||||
|
||||
fake_ctx = types.ModuleType("agent.context_references")
|
||||
fake_ctx.preprocess_context_references = lambda message, **kwargs: types.SimpleNamespace(
|
||||
blocked=False, message="expanded prompt", warnings=[], references=[], injected_tokens=0
|
||||
)
|
||||
fake_meta = types.ModuleType("agent.model_metadata")
|
||||
fake_meta.get_model_context_length = lambda *args, **kwargs: 100000
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
||||
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
monkeypatch.setitem(sys.modules, "agent.context_references", fake_ctx)
|
||||
monkeypatch.setitem(sys.modules, "agent.model_metadata", fake_meta)
|
||||
|
||||
server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "@diff"}})
|
||||
|
||||
assert captured["prompt"] == "expanded prompt"
|
||||
|
||||
|
||||
def test_image_attach_appends_local_image(monkeypatch):
|
||||
fake_cli = types.ModuleType("cli")
|
||||
fake_cli._IMAGE_EXTENSIONS = {".png"}
|
||||
fake_cli._split_path_input = lambda raw: (raw, "")
|
||||
fake_cli._resolve_attachment_path = lambda raw: Path("/tmp/cat.png")
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": "/tmp/cat.png"}})
|
||||
|
||||
assert resp["result"]["attached"] is True
|
||||
assert resp["result"]["name"] == "cat.png"
|
||||
assert len(server._sessions["sid"]["attached_images"]) == 1
|
||||
|
||||
|
||||
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}})
|
||||
monkeypatch.setattr(
|
||||
server.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: types.SimpleNamespace(returncode=1, stdout="", stderr="failed"),
|
||||
)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "command.dispatch", "params": {"name": "boom"}})
|
||||
|
||||
assert "error" in resp
|
||||
assert "failed" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_plugins_list_surfaces_loader_error(monkeypatch):
|
||||
with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")):
|
||||
resp = server.handle_request({"id": "1", "method": "plugins.list", "params": {}})
|
||||
|
||||
assert "error" in resp
|
||||
assert "boom" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_complete_slash_surfaces_completer_error(monkeypatch):
|
||||
with patch("hermes_cli.commands.SlashCommandCompleter", side_effect=Exception("no completer")):
|
||||
resp = server.handle_request({"id": "1", "method": "complete.slash", "params": {"text": "/mo"}})
|
||||
|
||||
assert "error" in resp
|
||||
assert "no completer" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_input_detect_drop_attaches_image(monkeypatch):
|
||||
fake_cli = types.ModuleType("cli")
|
||||
fake_cli._detect_file_drop = lambda raw: {
|
||||
"path": Path("/tmp/cat.png"),
|
||||
"is_image": True,
|
||||
"remainder": "",
|
||||
}
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "input.detect_drop", "params": {"session_id": "sid", "text": "/tmp/cat.png"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["matched"] is True
|
||||
assert resp["result"]["is_image"] is True
|
||||
assert resp["result"]["text"] == "[User attached image: cat.png]"
|
||||
|
||||
|
||||
def test_rollback_restore_resolves_number_and_file_path():
|
||||
calls = {}
|
||||
|
||||
class _Mgr:
|
||||
enabled = True
|
||||
|
||||
def list_checkpoints(self, cwd):
|
||||
return [{"hash": "aaa111"}, {"hash": "bbb222"}]
|
||||
|
||||
def restore(self, cwd, target, file_path=None):
|
||||
calls["args"] = (cwd, target, file_path)
|
||||
return {"success": True, "message": "done"}
|
||||
|
||||
server._sessions["sid"] = _session(agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[])
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "rollback.restore",
|
||||
"params": {"session_id": "sid", "hash": "2", "file_path": "src/app.tsx"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["success"] is True
|
||||
assert calls["args"][1] == "bbb222"
|
||||
assert calls["args"][2] == "src/app.tsx"
|
||||
|
|
@ -250,6 +250,15 @@ class TestWslHasImage:
|
|||
mock_run.return_value = MagicMock(stdout="False\n", returncode=0)
|
||||
assert _wsl_has_image() is False
|
||||
|
||||
def test_falls_back_to_get_clipboard_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="False\n", returncode=0),
|
||||
MagicMock(stdout="True\n", returncode=0),
|
||||
]
|
||||
assert _wsl_has_image() is True
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_powershell_not_found(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wsl_has_image() is False
|
||||
|
|
@ -269,6 +278,18 @@ class TestWslSave:
|
|||
assert _wsl_save(dest) is True
|
||||
assert dest.read_bytes() == FAKE_PNG
|
||||
|
||||
def test_falls_back_to_get_clipboard_extraction(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
b64_png = base64.b64encode(FAKE_PNG).decode()
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="", returncode=1),
|
||||
MagicMock(stdout=b64_png + "\n", returncode=0),
|
||||
]
|
||||
assert _wsl_save(dest) is True
|
||||
assert mock_run.call_count == 2
|
||||
assert dest.read_bytes() == FAKE_PNG
|
||||
|
||||
def test_no_image_returns_false(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
|
|
@ -528,6 +549,16 @@ class TestWindowsHasImage:
|
|||
mock_run.return_value = MagicMock(stdout="False\n", returncode=0)
|
||||
assert _windows_has_image() is False
|
||||
|
||||
def test_falls_back_to_get_clipboard_image(self):
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="False\n", returncode=0),
|
||||
MagicMock(stdout="True\n", returncode=0),
|
||||
]
|
||||
assert _windows_has_image() is True
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_no_powershell_available(self):
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value=None):
|
||||
assert _windows_has_image() is False
|
||||
|
|
@ -559,6 +590,20 @@ class TestWindowsSave:
|
|||
assert _windows_save(dest) is True
|
||||
assert dest.read_bytes() == FAKE_PNG
|
||||
|
||||
def test_falls_back_to_filedrop_image(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
b64_png = base64.b64encode(FAKE_PNG).decode()
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.side_effect = [
|
||||
MagicMock(stdout="", returncode=1),
|
||||
MagicMock(stdout="", returncode=1),
|
||||
MagicMock(stdout=b64_png + "\n", returncode=0),
|
||||
]
|
||||
assert _windows_save(dest) is True
|
||||
assert mock_run.call_count == 3
|
||||
assert dest.read_bytes() == FAKE_PNG
|
||||
|
||||
def test_no_image_returns_false(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
|
|
@ -734,6 +779,18 @@ class TestHasClipboardImage:
|
|||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
def test_wsl_falls_through_to_wayland_when_windows_path_empty(self):
|
||||
"""WSLg often bridges images to wl-paste even when powershell.exe check fails."""
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=True):
|
||||
with patch("hermes_cli.clipboard._wsl_has_image", return_value=False) as wsl:
|
||||
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
||||
with patch("hermes_cli.clipboard._wayland_has_image", return_value=True) as wl:
|
||||
assert has_clipboard_image() is True
|
||||
wsl.assert_called_once()
|
||||
wl.assert_called_once()
|
||||
|
||||
def test_linux_wayland_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
|
|
|
|||
0
tests/tui_gateway/__init__.py
Normal file
0
tests/tui_gateway/__init__.py
Normal file
233
tests/tui_gateway/test_protocol.py
Normal file
233
tests/tui_gateway/test_protocol.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Tests for tui_gateway JSON-RPC protocol plumbing."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_original_stdout = sys.stdout
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_stdout():
|
||||
yield
|
||||
sys.stdout = _original_stdout
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server():
|
||||
with patch.dict("sys.modules", {
|
||||
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
|
||||
"hermes_cli.env_loader": MagicMock(),
|
||||
"hermes_cli.banner": MagicMock(),
|
||||
"hermes_state": MagicMock(),
|
||||
}):
|
||||
import importlib
|
||||
mod = importlib.import_module("tui_gateway.server")
|
||||
yield mod
|
||||
mod._sessions.clear()
|
||||
mod._pending.clear()
|
||||
mod._answers.clear()
|
||||
mod._methods.clear()
|
||||
importlib.reload(mod)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def capture(server):
|
||||
"""Redirect server's real stdout to a StringIO and return (server, buf)."""
|
||||
buf = io.StringIO()
|
||||
server._real_stdout = buf
|
||||
return server, buf
|
||||
|
||||
|
||||
# ── JSON-RPC envelope ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unknown_method(server):
|
||||
resp = server.handle_request({"id": "1", "method": "bogus"})
|
||||
assert resp["error"]["code"] == -32601
|
||||
|
||||
|
||||
def test_ok_envelope(server):
|
||||
assert server._ok("r1", {"x": 1}) == {
|
||||
"jsonrpc": "2.0", "id": "r1", "result": {"x": 1},
|
||||
}
|
||||
|
||||
|
||||
def test_err_envelope(server):
|
||||
assert server._err("r2", 4001, "nope") == {
|
||||
"jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"},
|
||||
}
|
||||
|
||||
|
||||
# ── write_json ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_write_json(capture):
|
||||
server, buf = capture
|
||||
assert server.write_json({"test": True})
|
||||
assert json.loads(buf.getvalue()) == {"test": True}
|
||||
|
||||
|
||||
def test_write_json_broken_pipe(server):
|
||||
class _Broken:
|
||||
def write(self, _): raise BrokenPipeError
|
||||
def flush(self): raise BrokenPipeError
|
||||
|
||||
server._real_stdout = _Broken()
|
||||
assert server.write_json({"x": 1}) is False
|
||||
|
||||
|
||||
# ── _emit ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_emit_with_payload(capture):
|
||||
server, buf = capture
|
||||
server._emit("test.event", "s1", {"key": "val"})
|
||||
msg = json.loads(buf.getvalue())
|
||||
|
||||
assert msg["method"] == "event"
|
||||
assert msg["params"]["type"] == "test.event"
|
||||
assert msg["params"]["session_id"] == "s1"
|
||||
assert msg["params"]["payload"]["key"] == "val"
|
||||
|
||||
|
||||
def test_emit_without_payload(capture):
|
||||
server, buf = capture
|
||||
server._emit("ping", "s2")
|
||||
|
||||
assert "payload" not in json.loads(buf.getvalue())["params"]
|
||||
|
||||
|
||||
# ── Blocking prompt round-trip ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_and_respond(capture):
|
||||
server, _ = capture
|
||||
result = [None]
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
|
||||
).start()
|
||||
|
||||
for _ in range(100):
|
||||
if server._pending:
|
||||
break
|
||||
threading.Event().wait(0.01)
|
||||
|
||||
rid = next(iter(server._pending))
|
||||
server._answers[rid] = "my_answer"
|
||||
server._pending[rid].set()
|
||||
|
||||
threading.Event().wait(0.1)
|
||||
assert result[0] == "my_answer"
|
||||
|
||||
|
||||
def test_clear_pending(server):
|
||||
ev = threading.Event()
|
||||
server._pending["r1"] = ev
|
||||
server._clear_pending()
|
||||
|
||||
assert ev.is_set()
|
||||
assert server._answers["r1"] == ""
|
||||
|
||||
|
||||
# ── Session lookup ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sess_missing(server):
|
||||
_, err = server._sess({"session_id": "nope"}, "r1")
|
||||
assert err["error"]["code"] == 4001
|
||||
|
||||
|
||||
def test_sess_found(server):
|
||||
server._sessions["abc"] = {"agent": MagicMock()}
|
||||
s, err = server._sess({"session_id": "abc"}, "r1")
|
||||
|
||||
assert s is not None
|
||||
assert err is None
|
||||
|
||||
|
||||
# ── session.resume payload ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_session_resume_returns_hydrated_messages(server, monkeypatch):
|
||||
class _DB:
|
||||
def get_session(self, _sid):
|
||||
return {"id": "20260409_010101_abc123"}
|
||||
|
||||
def get_session_by_title(self, _title):
|
||||
return None
|
||||
|
||||
def reopen_session(self, _sid):
|
||||
return None
|
||||
|
||||
def get_messages_as_conversation(self, _sid):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "yo"},
|
||||
{"role": "tool", "content": "searched"},
|
||||
{"role": "assistant", "content": " "},
|
||||
{"role": "assistant", "content": None},
|
||||
{"role": "narrator", "content": "skip"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _DB())
|
||||
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object())
|
||||
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "test/model"})
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "r1",
|
||||
"method": "session.resume",
|
||||
"params": {"session_id": "20260409_010101_abc123", "cols": 100},
|
||||
}
|
||||
)
|
||||
|
||||
assert "error" not in resp
|
||||
assert resp["result"]["message_count"] == 3
|
||||
assert resp["result"]["messages"] == [
|
||||
{"role": "user", "text": "hello"},
|
||||
{"role": "assistant", "text": "yo"},
|
||||
{"role": "tool", "name": "tool", "context": ""},
|
||||
]
|
||||
|
||||
|
||||
# ── Config I/O ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_config_load_missing(server, tmp_path):
|
||||
server._hermes_home = tmp_path
|
||||
assert server._load_cfg() == {}
|
||||
|
||||
|
||||
def test_config_roundtrip(server, tmp_path):
|
||||
server._hermes_home = tmp_path
|
||||
server._save_cfg({"model": "test/model"})
|
||||
assert server._load_cfg()["model"] == "test/model"
|
||||
|
||||
|
||||
# ── _cli_exec_blocked ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argv", [
|
||||
[],
|
||||
["setup"],
|
||||
["gateway"],
|
||||
["sessions", "browse"],
|
||||
["config", "edit"],
|
||||
])
|
||||
def test_cli_exec_blocked(server, argv):
|
||||
assert server._cli_exec_blocked(argv) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argv", [
|
||||
["version"],
|
||||
["sessions", "list"],
|
||||
])
|
||||
def test_cli_exec_allowed(server, argv):
|
||||
assert server._cli_exec_blocked(argv) is None
|
||||
67
tests/tui_gateway/test_render.py
Normal file
67
tests/tui_gateway/test_render.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Tests for tui_gateway.render — rendering bridge fallback behavior."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tui_gateway.render import make_stream_renderer, render_diff, render_message
|
||||
|
||||
|
||||
def _stub_rich(mock_mod):
|
||||
return patch.dict("sys.modules", {"agent.rich_output": mock_mod})
|
||||
|
||||
|
||||
def _no_rich():
|
||||
return patch.dict("sys.modules", {"agent.rich_output": None})
|
||||
|
||||
|
||||
# ── render_message ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_message_none_without_module():
|
||||
with _no_rich():
|
||||
assert render_message("hello") is None
|
||||
|
||||
|
||||
def test_render_message_formatted():
|
||||
mod = MagicMock()
|
||||
mod.format_response.return_value = "<b>hi</b>"
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert render_message("hi", 100) == "<b>hi</b>"
|
||||
|
||||
|
||||
def test_render_message_type_error_fallback():
|
||||
mod = MagicMock()
|
||||
mod.format_response.side_effect = [TypeError, "fallback"]
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert render_message("hi") == "fallback"
|
||||
|
||||
|
||||
def test_render_message_exception_returns_none():
|
||||
mod = MagicMock()
|
||||
mod.format_response.side_effect = RuntimeError
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert render_message("hi") is None
|
||||
|
||||
|
||||
# ── render_diff / make_stream_renderer ───────────────────────────────
|
||||
|
||||
|
||||
def test_render_diff_none_without_module():
|
||||
with _no_rich():
|
||||
assert render_diff("+line") is None
|
||||
|
||||
|
||||
def test_stream_renderer_none_without_module():
|
||||
with _no_rich():
|
||||
assert make_stream_renderer() is None
|
||||
|
||||
|
||||
def test_stream_renderer_returns_instance():
|
||||
renderer = MagicMock()
|
||||
mod = MagicMock()
|
||||
mod.StreamingRenderer.return_value = renderer
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert make_stream_renderer(120) is renderer
|
||||
|
|
@ -155,7 +155,7 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
|
|||
return [t for t in toolsets if t not in blocked_toolset_names]
|
||||
|
||||
|
||||
def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]:
|
||||
def _build_child_progress_callback(task_index: int, goal: str, parent_agent, task_count: int = 1) -> Optional[callable]:
|
||||
"""Build a callback that relays child agent tool calls to the parent display.
|
||||
|
||||
Two display paths:
|
||||
|
|
@ -173,14 +173,46 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
|
|||
|
||||
# Show 1-indexed prefix only in batch mode (multiple tasks)
|
||||
prefix = f"[{task_index + 1}] " if task_count > 1 else ""
|
||||
goal_label = (goal or "").strip()
|
||||
|
||||
# Gateway: batch tool names, flush periodically
|
||||
_BATCH_SIZE = 5
|
||||
_batch: List[str] = []
|
||||
|
||||
def _relay(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
|
||||
if not parent_cb:
|
||||
return
|
||||
try:
|
||||
parent_cb(
|
||||
event_type,
|
||||
tool_name,
|
||||
preview,
|
||||
args,
|
||||
task_index=task_index,
|
||||
task_count=task_count,
|
||||
goal=goal_label,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Parent callback failed: %s", e)
|
||||
|
||||
def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
|
||||
# event_type is one of: "tool.started", "tool.completed",
|
||||
# "reasoning.available", "_thinking", "subagent_progress"
|
||||
# "reasoning.available", "_thinking", "subagent.*"
|
||||
|
||||
if event_type == "subagent.start":
|
||||
if spinner and goal_label:
|
||||
short = (goal_label[:55] + "...") if len(goal_label) > 55 else goal_label
|
||||
try:
|
||||
spinner.print_above(f" {prefix}├─ 🔀 {short}")
|
||||
except Exception as e:
|
||||
logger.debug("Spinner print_above failed: %s", e)
|
||||
_relay("subagent.start", preview=preview or goal_label or "", **kwargs)
|
||||
return
|
||||
|
||||
if event_type == "subagent.complete":
|
||||
_relay("subagent.complete", preview=preview, **kwargs)
|
||||
return
|
||||
|
||||
# "_thinking" / reasoning events
|
||||
if event_type in ("_thinking", "reasoning.available"):
|
||||
|
|
@ -191,7 +223,7 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
|
|||
spinner.print_above(f" {prefix}├─ 💭 \"{short}\"")
|
||||
except Exception as e:
|
||||
logger.debug("Spinner print_above failed: %s", e)
|
||||
# Don't relay thinking to gateway (too noisy for chat)
|
||||
_relay("subagent.thinking", preview=text)
|
||||
return
|
||||
|
||||
# tool.completed — no display needed here (spinner shows on started)
|
||||
|
|
@ -212,23 +244,18 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in
|
|||
logger.debug("Spinner print_above failed: %s", e)
|
||||
|
||||
if parent_cb:
|
||||
_relay("subagent.tool", tool_name, preview, args)
|
||||
_batch.append(tool_name or "")
|
||||
if len(_batch) >= _BATCH_SIZE:
|
||||
summary = ", ".join(_batch)
|
||||
try:
|
||||
parent_cb("subagent_progress", f"🔀 {prefix}{summary}")
|
||||
except Exception as e:
|
||||
logger.debug("Parent callback failed: %s", e)
|
||||
_relay("subagent.progress", preview=f"🔀 {prefix}{summary}")
|
||||
_batch.clear()
|
||||
|
||||
def _flush():
|
||||
"""Flush remaining batched tool names to gateway on completion."""
|
||||
if parent_cb and _batch:
|
||||
summary = ", ".join(_batch)
|
||||
try:
|
||||
parent_cb("subagent_progress", f"🔀 {prefix}{summary}")
|
||||
except Exception as e:
|
||||
logger.debug("Parent callback flush failed: %s", e)
|
||||
_relay("subagent.progress", preview=f"🔀 {prefix}{summary}")
|
||||
_batch.clear()
|
||||
|
||||
_callback._flush = _flush
|
||||
|
|
@ -242,6 +269,7 @@ def _build_child_agent(
|
|||
toolsets: Optional[List[str]],
|
||||
model: Optional[str],
|
||||
max_iterations: int,
|
||||
task_count: int,
|
||||
parent_agent,
|
||||
# Credential overrides from delegation config (provider:model resolution)
|
||||
override_provider: Optional[str] = None,
|
||||
|
|
@ -298,7 +326,7 @@ def _build_child_agent(
|
|||
parent_api_key = parent_agent._client_kwargs.get("api_key")
|
||||
|
||||
# Build progress callback to relay tool calls to parent display
|
||||
child_progress_cb = _build_child_progress_callback(task_index, parent_agent)
|
||||
child_progress_cb = _build_child_progress_callback(task_index, goal, parent_agent, task_count)
|
||||
|
||||
# Each subagent gets its own iteration budget capped at max_iterations
|
||||
# (configurable via delegation.max_iterations, default 50). This means
|
||||
|
|
@ -469,6 +497,12 @@ def _run_single_child(
|
|||
_heartbeat_thread.start()
|
||||
|
||||
try:
|
||||
if child_progress_cb:
|
||||
try:
|
||||
child_progress_cb("subagent.start", preview=goal)
|
||||
except Exception as e:
|
||||
logger.debug("Progress callback start failed: %s", e)
|
||||
|
||||
result = child.run_conversation(user_message=goal)
|
||||
|
||||
# Flush any remaining batched progress to gateway
|
||||
|
|
@ -563,11 +597,34 @@ def _run_single_child(
|
|||
if status == "failed":
|
||||
entry["error"] = result.get("error", "Subagent did not produce a response.")
|
||||
|
||||
if child_progress_cb:
|
||||
try:
|
||||
child_progress_cb(
|
||||
"subagent.complete",
|
||||
preview=summary[:160] if summary else entry.get("error", ""),
|
||||
status=status,
|
||||
duration_seconds=duration,
|
||||
summary=summary[:500] if summary else entry.get("error", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Progress callback completion failed: %s", e)
|
||||
|
||||
return entry
|
||||
|
||||
except Exception as exc:
|
||||
duration = round(time.monotonic() - child_start, 2)
|
||||
logging.exception(f"[subagent-{task_index}] failed")
|
||||
if child_progress_cb:
|
||||
try:
|
||||
child_progress_cb(
|
||||
"subagent.complete",
|
||||
preview=str(exc),
|
||||
status="failed",
|
||||
duration_seconds=duration,
|
||||
summary=str(exc),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Progress callback failure relay failed: %s", e)
|
||||
return {
|
||||
"task_index": task_index,
|
||||
"status": "error",
|
||||
|
|
@ -714,7 +771,7 @@ def delegate_task(
|
|||
child = _build_child_agent(
|
||||
task_index=i, goal=t["goal"], context=t.get("context"),
|
||||
toolsets=t.get("toolsets") or toolsets, model=creds["model"],
|
||||
max_iterations=effective_max_iter, parent_agent=parent_agent,
|
||||
max_iterations=effective_max_iter, task_count=n_tasks, parent_agent=parent_agent,
|
||||
override_provider=creds["provider"], override_base_url=creds["base_url"],
|
||||
override_api_key=creds["api_key"],
|
||||
override_api_mode=creds["api_mode"],
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@ WATCH_WINDOW_SECONDS = 10 # Rolling window length
|
|||
WATCH_OVERLOAD_KILL_SECONDS = 45 # Sustained overload duration before disabling watch
|
||||
|
||||
|
||||
def format_uptime_short(seconds: int) -> str:
|
||||
s = max(0, int(seconds))
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
mins, secs = divmod(s, 60)
|
||||
if mins < 60:
|
||||
return f"{mins}m {secs}s"
|
||||
hours, mins = divmod(mins, 60)
|
||||
return f"{hours}h {mins}m"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessSession:
|
||||
"""A tracked background process with output buffering."""
|
||||
|
|
|
|||
0
tui_gateway/__init__.py
Normal file
0
tui_gateway/__init__.py
Normal file
38
tui_gateway/entry.py
Normal file
38
tui_gateway/entry.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import json
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from tui_gateway.server import handle_request, resolve_skin, write_json
|
||||
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
|
||||
def main():
|
||||
if not write_json({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}},
|
||||
}):
|
||||
sys.exit(0)
|
||||
|
||||
for raw in sys.stdin:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}):
|
||||
sys.exit(0)
|
||||
continue
|
||||
|
||||
resp = handle_request(req)
|
||||
if resp is not None:
|
||||
if not write_json(resp):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
49
tui_gateway/render.py
Normal file
49
tui_gateway/render.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Rendering bridge — routes TUI content through Python-side renderers.
|
||||
|
||||
When agent.rich_output exists, its functions are used. When it doesn't,
|
||||
everything returns None and the TUI falls back to its own markdown.tsx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def render_message(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import format_response
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return format_response(text, cols=cols)
|
||||
except TypeError:
|
||||
return format_response(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def render_diff(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import render_diff as _rd
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return _rd(text, cols=cols)
|
||||
except TypeError:
|
||||
return _rd(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def make_stream_renderer(cols: int = 80):
|
||||
try:
|
||||
from agent.rich_output import StreamingRenderer
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return StreamingRenderer(cols=cols)
|
||||
except TypeError:
|
||||
return StreamingRenderer()
|
||||
except Exception:
|
||||
return None
|
||||
2778
tui_gateway/server.py
Normal file
2778
tui_gateway/server.py
Normal file
File diff suppressed because it is too large
Load diff
76
tui_gateway/slash_worker.py
Normal file
76
tui_gateway/slash_worker.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Persistent slash-command worker — one HermesCLI per TUI session.
|
||||
|
||||
Protocol: reads JSON lines from stdin {id, command}, writes {id, ok, output|error} to stdout.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
from rich.console import Console
|
||||
|
||||
|
||||
def _run(cli: HermesCLI, command: str) -> str:
|
||||
cmd = (command or "").strip()
|
||||
if not cmd:
|
||||
return ""
|
||||
if not cmd.startswith("/"):
|
||||
cmd = f"/{cmd}"
|
||||
|
||||
buf = io.StringIO()
|
||||
|
||||
# Rich Console captures its file handle at construction time, so
|
||||
# contextlib.redirect_stdout won't affect it. Swap the console's
|
||||
# underlying file to our buffer so self.console.print() is captured.
|
||||
cli.console = Console(file=buf, force_terminal=True, width=120)
|
||||
|
||||
old = getattr(cli_mod, "_cprint", None)
|
||||
if old is not None:
|
||||
cli_mod._cprint = lambda text: print(text)
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||||
cli.process_command(cmd)
|
||||
finally:
|
||||
if old is not None:
|
||||
cli_mod._cprint = old
|
||||
|
||||
return buf.getvalue().rstrip()
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(add_help=False)
|
||||
p.add_argument("--session-key", required=True)
|
||||
p.add_argument("--model", default="")
|
||||
args = p.parse_args()
|
||||
|
||||
os.environ["HERMES_SESSION_KEY"] = args.session_key
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
|
||||
|
||||
for raw in sys.stdin:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
rid = None
|
||||
try:
|
||||
req = json.loads(line)
|
||||
rid = req.get("id")
|
||||
out = _run(cli, req.get("command", ""))
|
||||
sys.stdout.write(json.dumps({"id": rid, "ok": True, "output": out}) + "\n")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
ui-tui/.gitignore
vendored
Normal file
4
ui-tui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
dist/
|
||||
node_modules/
|
||||
src/*.js
|
||||
docs/
|
||||
11
ui-tui/.prettierrc
Normal file
11
ui-tui/.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
|
||||
}
|
||||
347
ui-tui/README.md
Normal file
347
ui-tui/README.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# Hermes TUI
|
||||
|
||||
React + Ink terminal UI for Hermes. TypeScript owns the screen. Python owns sessions, tools, model calls, and most command logic.
|
||||
|
||||
```bash
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
## What runs
|
||||
|
||||
The client entrypoint is `src/entry.tsx`. It exits early if `stdin` is not a TTY, starts `GatewayClient`, then renders `App`.
|
||||
|
||||
`GatewayClient` spawns:
|
||||
|
||||
```text
|
||||
python -m tui_gateway.entry
|
||||
```
|
||||
|
||||
Interpreter resolution order is: `HERMES_PYTHON` → `PYTHON` → `$VIRTUAL_ENV/bin/python` → `./.venv/bin/python` → `./venv/bin/python` → `python3` (or `python` on Windows).
|
||||
|
||||
The transport is newline-delimited JSON-RPC over stdio:
|
||||
|
||||
```text
|
||||
ui-tui/src tui_gateway/
|
||||
----------- -------------
|
||||
entry.tsx entry.py
|
||||
-> GatewayClient -> request loop
|
||||
-> App -> server.py RPC handlers
|
||||
|
||||
stdin/stdout: JSON-RPC requests, responses, events
|
||||
stderr: captured into an in-memory log ring
|
||||
```
|
||||
|
||||
Malformed stdout lines are treated as protocol noise and surfaced as `gateway.protocol_error`. Stderr lines become `gateway.stderr`. Neither writes directly into the terminal.
|
||||
|
||||
## Running it
|
||||
|
||||
From the repo root, the normal path is:
|
||||
|
||||
```bash
|
||||
hermes --tui
|
||||
```
|
||||
|
||||
The CLI expects `ui-tui/node_modules` to exist. If the TUI deps are missing:
|
||||
|
||||
```bash
|
||||
cd ui-tui
|
||||
npm install
|
||||
```
|
||||
|
||||
Local package commands:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm start
|
||||
npm run build
|
||||
npm run lint
|
||||
npm run fmt
|
||||
npm run fix
|
||||
```
|
||||
|
||||
Tests use vitest:
|
||||
|
||||
```bash
|
||||
npm test # single run
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## App model
|
||||
|
||||
`src/app.tsx` is the center of the UI. Heavy logic is split into `src/app/`:
|
||||
|
||||
- `createGatewayEventHandler.ts` — maps gateway events to state updates
|
||||
- `createSlashHandler.ts` — local slash command dispatch
|
||||
- `useComposerState.ts` — draft, multiline buffer, queue editing
|
||||
- `useInputHandlers.ts` — keypress routing
|
||||
- `useTurnState.ts` — agent turn lifecycle
|
||||
- `overlayStore.ts` / `uiStore.ts` — nanostores for overlay and UI state
|
||||
- `gatewayContext.tsx` — React context for the gateway client
|
||||
- `constants.ts`, `helpers.ts`, `interfaces.ts`
|
||||
|
||||
The top-level `app.tsx` composes these into the Ink tree with `Static` transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list.
|
||||
|
||||
State managed at the top level includes:
|
||||
|
||||
- transcript and streaming state
|
||||
- queued messages and input history
|
||||
- session lifecycle
|
||||
- tool progress and reasoning text
|
||||
- prompt flows for approval, clarify, sudo, and secret input
|
||||
- slash command routing
|
||||
- tab completion and path completion
|
||||
- theme state from gateway skin data
|
||||
|
||||
The UI renders as a normal Ink tree with `Static` transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list.
|
||||
|
||||
The intro panel is driven by `session.info` and rendered through `branding.tsx`.
|
||||
|
||||
## Hotkeys and interactions
|
||||
|
||||
Current input behavior is split across `app.tsx`, `components/textInput.tsx`, and the prompt/picker components.
|
||||
|
||||
### Main chat input
|
||||
|
||||
| Key | Behavior |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Enter` | Submit the current draft |
|
||||
| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message |
|
||||
| `Shift+Enter` / `Alt+Enter` | Insert a newline in the current draft |
|
||||
| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) |
|
||||
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
||||
| `Ctrl+D` | Exit |
|
||||
| `Ctrl+G` | Open `$EDITOR` with the current draft |
|
||||
| `Ctrl+L` | New session (same as `/clear`) |
|
||||
| `Ctrl+V` / `Alt+V` | Paste clipboard image (same as `/paste`) |
|
||||
| `Tab` | Apply the active completion |
|
||||
| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history |
|
||||
| `Left/Right` | Move the cursor |
|
||||
| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key |
|
||||
| `Home` / `Ctrl+A` | Start of line |
|
||||
| `End` / `Ctrl+E` | End of line |
|
||||
| `Backspace` | Delete the character to the left of the cursor |
|
||||
| `Delete` | Delete the character to the right of the cursor |
|
||||
| modified `Backspace` | Delete the previous word |
|
||||
| modified `Delete` | Delete the next word |
|
||||
| `Ctrl+W` | Delete the previous word |
|
||||
| `Ctrl+U` | Delete from the cursor back to the start of the line |
|
||||
| `Ctrl+K` | Delete from the cursor to the end of the line |
|
||||
| `Meta+B` / `Meta+F` | Move by word |
|
||||
| `!cmd` | Run a shell command through the gateway |
|
||||
| `{!cmd}` | Inline shell interpolation before send; queued drafts keep the raw text until they are sent |
|
||||
|
||||
Notes:
|
||||
|
||||
- `Tab` only applies completions when completions are present and you are not in multiline mode.
|
||||
- Queue/history navigation only applies when you are not in multiline mode.
|
||||
- `PgUp` / `PgDn` are left to the terminal emulator; the TUI does not handle them.
|
||||
|
||||
### Prompt and picker modes
|
||||
|
||||
| Context | Keys | Behavior |
|
||||
| --------------------------- | ------------------- | ------------------------------------------------- |
|
||||
| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice |
|
||||
| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` |
|
||||
| approval prompt | `Esc`, `Ctrl+C` | Deny |
|
||||
| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice |
|
||||
| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice |
|
||||
| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry |
|
||||
| clarify free-text mode | `Enter` | Submit typed answer |
|
||||
| sudo / secret prompt | `Enter` | Submit typed value |
|
||||
| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response |
|
||||
| resume picker | `Up/Down`, `Enter` | Move and resume the selected session |
|
||||
| resume picker | `1-9` | Quick-pick one of the first nine visible sessions |
|
||||
| resume picker | `Esc`, `Ctrl+C` | Close the picker |
|
||||
|
||||
Notes:
|
||||
|
||||
- Clarify free-text mode and masked prompts use `ink-text-input`, so text editing there follows the library's default bindings rather than `components/textInput.tsx`.
|
||||
- When a blocking prompt is open, the main chat input hotkeys are suspended.
|
||||
- Clarify mode has no dedicated cancel shortcut in the current client. Sudo and secret prompts only expose `Ctrl+C` cancellation from the app-level blocked handler.
|
||||
|
||||
### Interaction rules
|
||||
|
||||
- Plain text entered while the agent is busy is queued instead of sent immediately.
|
||||
- Slash commands and `!cmd` do not queue; they execute immediately even while a run is active.
|
||||
- Queue auto-drains after each assistant response, unless a queued item is currently being edited.
|
||||
- `Up/Down` prioritizes queued-message editing over history. History only activates when there is no queue to edit.
|
||||
- Queued drafts keep their original `!cmd` and `{!cmd}` text while you edit them. Shell commands and interpolation run when the queued item is actually sent.
|
||||
- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes.
|
||||
- Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`.
|
||||
- Text pastes are inserted inline directly into the draft. Nothing is newline-flattened.
|
||||
- `Ctrl+G` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly.
|
||||
- Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`.
|
||||
|
||||
## Rendering
|
||||
|
||||
Assistant output is rendered in one of two ways:
|
||||
|
||||
- if the payload already contains ANSI, `messageLine.tsx` prints it directly
|
||||
- otherwise `components/markdown.tsx` renders a small Markdown subset into Ink components
|
||||
|
||||
The Markdown renderer handles headings, lists, block quotes, tables, fenced code blocks, diff coloring, inline code, emphasis, links, and plain URLs.
|
||||
|
||||
Tool/status activity is shown in a live activity lane. Transcript rows stay focused on user/assistant turns.
|
||||
|
||||
## Prompt flows
|
||||
|
||||
The Python gateway can pause the main loop and request structured input:
|
||||
|
||||
- `approval.request`: allow once, allow for session, allow always, or deny
|
||||
- `clarify.request`: pick from choices or type a custom answer
|
||||
- `sudo.request`: masked password entry
|
||||
- `secret.request`: masked value entry for a named env var
|
||||
- `session.list`: used by `SessionPicker` for `/resume`
|
||||
|
||||
These are stateful UI branches in `app.tsx`, not separate screens.
|
||||
|
||||
## Commands
|
||||
|
||||
The local slash handler covers the built-ins that need direct client behavior:
|
||||
|
||||
- `/help`
|
||||
- `/quit`, `/exit`, `/q`
|
||||
- `/clear`
|
||||
- `/new`
|
||||
- `/compact`
|
||||
- `/resume`
|
||||
- `/copy`
|
||||
- `/paste`
|
||||
- `/details`
|
||||
- `/logs`
|
||||
- `/statusbar`, `/sb`
|
||||
- `/queue`
|
||||
- `/undo`
|
||||
- `/retry`
|
||||
|
||||
Notes:
|
||||
|
||||
- `/copy` sends the selected assistant response through OSC 52.
|
||||
- `/paste` with no args asks the gateway for clipboard image attachment state.
|
||||
- `/paste` does not manage text paste entries; text paste is inline-only.
|
||||
- `/details [hidden|collapsed|expanded|cycle]` controls thinking/tool-detail visibility.
|
||||
- `/statusbar` toggles the status rule on/off.
|
||||
|
||||
Anything else falls through to:
|
||||
|
||||
1. `slash.exec`
|
||||
2. `command.dispatch`
|
||||
|
||||
That lets Python own aliases, plugins, skills, and registry-backed commands without duplicating the logic in the TUI.
|
||||
|
||||
## Event surface
|
||||
|
||||
Primary event types the client handles today:
|
||||
|
||||
| Event | Payload |
|
||||
| ------------------------ | ----------------------------------------------- |
|
||||
| `gateway.ready` | `{ skin? }` |
|
||||
| `session.info` | session metadata for banner + tool/skill panels |
|
||||
| `message.start` | start assistant streaming |
|
||||
| `message.delta` | `{ text, rendered? }` |
|
||||
| `message.complete` | `{ text, rendered?, usage, status }` |
|
||||
| `thinking.delta` | `{ text }` |
|
||||
| `reasoning.delta` | `{ text }` |
|
||||
| `reasoning.available` | `{ text }` |
|
||||
| `status.update` | `{ kind, text }` |
|
||||
| `tool.start` | `{ tool_id, name, context? }` |
|
||||
| `tool.progress` | `{ name, preview }` |
|
||||
| `tool.complete` | `{ tool_id, name }` |
|
||||
| `clarify.request` | `{ question, choices?, request_id }` |
|
||||
| `approval.request` | `{ command, description }` |
|
||||
| `sudo.request` | `{ request_id }` |
|
||||
| `secret.request` | `{ prompt, env_var, request_id }` |
|
||||
| `background.complete` | `{ task_id, text }` |
|
||||
| `btw.complete` | `{ text }` |
|
||||
| `error` | `{ message }` |
|
||||
| `gateway.stderr` | synthesized from child stderr |
|
||||
| `gateway.protocol_error` | synthesized from malformed stdout |
|
||||
|
||||
## Theme model
|
||||
|
||||
The client starts with `DEFAULT_THEME` from `theme.ts`, then merges in gateway skin data from `gateway.ready`.
|
||||
|
||||
Current branding overrides:
|
||||
|
||||
- agent name
|
||||
- prompt symbol
|
||||
- welcome text
|
||||
- goodbye text
|
||||
|
||||
Current color overrides:
|
||||
|
||||
- banner title, accent, border, body, dim
|
||||
- label, ok, error, warn
|
||||
|
||||
`branding.tsx` uses those values for the logo, session panel, and update notice.
|
||||
|
||||
## File map
|
||||
|
||||
```text
|
||||
ui-tui/
|
||||
packages/hermes-ink/ forked Ink renderer (local dep)
|
||||
src/
|
||||
entry.tsx TTY gate + render()
|
||||
app.tsx top-level Ink tree, composes src/app/*
|
||||
gatewayClient.ts child process + JSON-RPC bridge
|
||||
theme.ts default palette + skin merge
|
||||
constants.ts display constants, hotkeys, tool labels
|
||||
types.ts shared client-side types
|
||||
banner.ts ASCII art data
|
||||
|
||||
app/
|
||||
createGatewayEventHandler.ts event → state mapping
|
||||
createSlashHandler.ts local slash dispatch
|
||||
useComposerState.ts draft + multiline + queue editing
|
||||
useInputHandlers.ts keypress routing
|
||||
useTurnState.ts agent turn lifecycle
|
||||
overlayStore.ts nanostores for overlays
|
||||
uiStore.ts nanostores for UI flags
|
||||
gatewayContext.tsx React context for gateway client
|
||||
constants.ts app-level constants
|
||||
helpers.ts pure helpers
|
||||
interfaces.ts internal interfaces
|
||||
|
||||
components/
|
||||
appChrome.tsx status bar, input row, completions
|
||||
appLayout.tsx top-level layout composition
|
||||
appOverlays.tsx overlay routing (pickers, prompts)
|
||||
branding.tsx banner + session summary
|
||||
markdown.tsx Markdown-to-Ink renderer
|
||||
maskedPrompt.tsx masked input for sudo / secrets
|
||||
messageLine.tsx transcript rows
|
||||
modelPicker.tsx model switch picker
|
||||
prompts.tsx approval + clarify flows
|
||||
queuedMessages.tsx queued input preview
|
||||
sessionPicker.tsx session resume picker
|
||||
textInput.tsx custom line editor
|
||||
thinking.tsx spinner, reasoning, tool activity
|
||||
|
||||
hooks/
|
||||
useCompletion.ts tab completion (slash + path)
|
||||
useInputHistory.ts persistent history navigation
|
||||
useQueue.ts queued message management
|
||||
useVirtualHistory.ts in-memory history for pickers
|
||||
|
||||
lib/
|
||||
history.ts persistent input history
|
||||
messages.ts message formatting helpers
|
||||
osc52.ts OSC 52 clipboard copy
|
||||
rpc.ts JSON-RPC type helpers
|
||||
text.ts text helpers, ANSI detection, previews
|
||||
|
||||
types/
|
||||
hermes-ink.d.ts type declarations for @hermes/ink
|
||||
|
||||
__tests__/ vitest suite
|
||||
```
|
||||
|
||||
Related Python side:
|
||||
|
||||
```text
|
||||
tui_gateway/
|
||||
entry.py stdio entrypoint
|
||||
server.py RPC handlers and session logic
|
||||
render.py optional rich/ANSI bridge
|
||||
slash_worker.py persistent HermesCLI subprocess for slash commands
|
||||
```
|
||||
107
ui-tui/eslint.config.mjs
Normal file
107
ui-tui/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import js from '@eslint/js'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
|
||||
const noopRule = {
|
||||
meta: { schema: [], type: 'problem' },
|
||||
create: () => ({})
|
||||
}
|
||||
|
||||
const customRules = {
|
||||
rules: {
|
||||
'no-process-cwd': noopRule,
|
||||
'no-process-env-top-level': noopRule,
|
||||
'no-sync-fs': noopRule,
|
||||
'no-top-level-dynamic-import': noopRule,
|
||||
'no-top-level-side-effects': noopRule
|
||||
}
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js']
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
rules: {
|
||||
'no-fallthrough': ['error', { allowEmptyCase: true }],
|
||||
curly: ['error', 'all'],
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'padding-line-between-statements': [
|
||||
1,
|
||||
{ blankLine: 'always', next: ['block-like', 'block', 'return', 'if', 'class', 'continue', 'debugger', 'break', 'multiline-const', 'multiline-let'], prev: '*' },
|
||||
{ blankLine: 'always', next: '*', prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like'] },
|
||||
{ blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] },
|
||||
{ blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] },
|
||||
{ blankLine: 'always', next: ['empty'], prev: 'export' },
|
||||
{ blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] }
|
||||
],
|
||||
'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
order: 'asc',
|
||||
type: 'natural'
|
||||
}
|
||||
],
|
||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' }
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['packages/hermes-ink/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': 'off',
|
||||
'no-constant-condition': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-redeclare': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**'],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['*.config.*']
|
||||
}
|
||||
]
|
||||
7246
ui-tui/package-lock.json
generated
Normal file
7246
ui-tui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
ui-tui/package.json
Normal file
43
ui-tui/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "hermes-tui",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx",
|
||||
"start": "tsx src/entry.tsx",
|
||||
"build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && chmod +x dist/entry.js",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||
"lint": "eslint src/ packages/",
|
||||
"lint:fix": "eslint src/ packages/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/ink": "file:./packages/hermes-ink",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"ink": "^6.8.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^19.2.4",
|
||||
"unicode-animations": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-perfectionist": "^5",
|
||||
"eslint-plugin-react": "^7",
|
||||
"eslint-plugin-react-hooks": "^7",
|
||||
"eslint-plugin-unused-imports": "^4",
|
||||
"globals": "^16",
|
||||
"prettier": "^3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
83
ui-tui/packages/hermes-ink/ambient.d.ts
vendored
Normal file
83
ui-tui/packages/hermes-ink/ambient.d.ts
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/// <reference types="react" />
|
||||
|
||||
declare module 'react/compiler-runtime' {
|
||||
export function c(size: number): any[]
|
||||
}
|
||||
|
||||
declare module 'bidi-js' {
|
||||
const bidiFactory: () => Record<string, any>
|
||||
export default bidiFactory
|
||||
}
|
||||
|
||||
declare module 'stack-utils' {
|
||||
class StackUtils {
|
||||
static nodeInternals(): RegExp[]
|
||||
constructor(opts?: { cwd?: string; internals?: RegExp[] })
|
||||
clean(stack: string | undefined): string | undefined
|
||||
parseLine(line: string): { file?: string; line?: number; column?: number; function?: string } | undefined
|
||||
}
|
||||
export default StackUtils
|
||||
}
|
||||
|
||||
declare module 'react-reconciler' {
|
||||
export type FiberRoot = unknown
|
||||
const createReconciler: any
|
||||
export default createReconciler
|
||||
}
|
||||
|
||||
declare module 'react-reconciler/constants.js' {
|
||||
export const ConcurrentRoot: number
|
||||
export const LegacyRoot: number
|
||||
export const DiscreteEventPriority: symbol | number
|
||||
export const ContinuousEventPriority: symbol | number
|
||||
export const DefaultEventPriority: symbol | number
|
||||
export const NoEventPriority: symbol | number
|
||||
}
|
||||
|
||||
declare module 'lodash-es/noop.js' {
|
||||
const noop: (...args: unknown[]) => void
|
||||
export default noop
|
||||
}
|
||||
|
||||
declare module 'lodash-es/throttle.js' {
|
||||
function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
wait?: number,
|
||||
opts?: { leading?: boolean; trailing?: boolean }
|
||||
): T & { cancel(): void; flush(): void }
|
||||
export default throttle
|
||||
}
|
||||
|
||||
declare module 'semver' {
|
||||
export function coerce(version: string | number | null | undefined): { version: string } | null
|
||||
export function gt(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function gte(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function lt(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function lte(a: string, b: string, opts?: { loose?: boolean }): boolean
|
||||
export function satisfies(version: string, range: string, opts?: { loose?: boolean }): boolean
|
||||
export function compare(a: string, b: string, opts?: { loose?: boolean }): number
|
||||
}
|
||||
|
||||
interface BunSemver {
|
||||
order(a: string, b: string): -1 | 0 | 1
|
||||
satisfies(version: string, range: string): boolean
|
||||
}
|
||||
|
||||
interface BunRuntime {
|
||||
stringWidth(s: string, opts?: { ambiguousIsNarrow?: boolean }): number
|
||||
semver: BunSemver
|
||||
wrapAnsi?(input: string, columns: number, options?: { hard?: boolean; wordWrap?: boolean; trim?: boolean }): string
|
||||
}
|
||||
|
||||
declare var Bun: BunRuntime | undefined
|
||||
|
||||
declare namespace React {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'ink-box': Record<string, unknown>
|
||||
'ink-text': Record<string, unknown>
|
||||
'ink-link': Record<string, unknown>
|
||||
'ink-raw-ansi': Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
35
ui-tui/packages/hermes-ink/index.d.ts
vendored
Normal file
35
ui-tui/packages/hermes-ink/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/// <reference path="./ambient.d.ts" />
|
||||
export { default as useStderr } from './src/hooks/use-stderr.ts'
|
||||
export type { StderrHandle } from './src/hooks/use-stderr.ts'
|
||||
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
||||
export type { StdoutHandle } from './src/hooks/use-stdout.ts'
|
||||
export { Ansi } from './src/ink/Ansi.tsx'
|
||||
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||
export { default as Box } from './src/ink/components/Box.tsx'
|
||||
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
||||
export { default as Link } from './src/ink/components/Link.tsx'
|
||||
export { default as Newline } from './src/ink/components/Newline.tsx'
|
||||
export { NoSelect } from './src/ink/components/NoSelect.tsx'
|
||||
export { RawAnsi } from './src/ink/components/RawAnsi.tsx'
|
||||
export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx'
|
||||
export type { ScrollBoxHandle, ScrollBoxProps } from './src/ink/components/ScrollBox.tsx'
|
||||
export { default as Spacer } from './src/ink/components/Spacer.tsx'
|
||||
export type { Props as StdinProps } from './src/ink/components/StdinContext.ts'
|
||||
export { default as Text } from './src/ink/components/Text.tsx'
|
||||
export type { Props as TextProps } from './src/ink/components/Text.tsx'
|
||||
export type { Key } from './src/ink/events/input-event.ts'
|
||||
export { default as useApp } from './src/ink/hooks/use-app.ts'
|
||||
export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts'
|
||||
export { default as useInput } from './src/ink/hooks/use-input.ts'
|
||||
export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts'
|
||||
export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
|
||||
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
|
||||
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||
export { createRoot, default as render, renderSync } from './src/ink/root.ts'
|
||||
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
|
||||
export { stringWidth } from './src/ink/stringWidth.ts'
|
||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||
export type { Props as TextInputProps } from 'ink-text-input'
|
||||
1
ui-tui/packages/hermes-ink/index.js
Normal file
1
ui-tui/packages/hermes-ink/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './dist/ink-bundle.js'
|
||||
819
ui-tui/packages/hermes-ink/package-lock.json
generated
Normal file
819
ui-tui/packages/hermes-ink/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,819 @@
|
|||
{
|
||||
"name": "@hermes/ink",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes/ink",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.0",
|
||||
"auto-bind": "^5.0.0",
|
||||
"bidi-js": "^1.0.0",
|
||||
"chalk": "^5.4.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"indent-string": "^5.0.0",
|
||||
"lodash-es": "^4.17.0",
|
||||
"react": ">=19.0.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"semver": "^7.6.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"stack-utils": "^2.0.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"supports-hyperlinks": "^3.1.0",
|
||||
"type-fest": "^4.30.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ink-text-input": ">=6.0.0",
|
||||
"react": ">=19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.1.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"environment": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/auto-bind": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
||||
"integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-boxes": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
||||
"integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
|
||||
"integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"restore-cursor": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz",
|
||||
"integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"slice-ansi": "^9.0.0",
|
||||
"string-width": "^8.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/code-excerpt": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
|
||||
"integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"convert-to-spaces": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-to-spaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
|
||||
"integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
|
||||
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
|
||||
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
|
||||
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-7.0.0.tgz",
|
||||
"integrity": "sha512-fMie5/VwIYXofMyND0s+fOVhwVBBPYx+uuqJ6V6rUBGjui+2UYp+0fWtvhSeKT4z+X1uH98a4ge5Vj3aTlL6mg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
"auto-bind": "^5.0.1",
|
||||
"chalk": "^5.6.2",
|
||||
"cli-boxes": "^4.0.1",
|
||||
"cli-cursor": "^4.0.0",
|
||||
"cli-truncate": "^6.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"indent-string": "^5.0.0",
|
||||
"is-in-ci": "^2.0.0",
|
||||
"patch-console": "^2.0.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"scheduler": "^0.27.0",
|
||||
"signal-exit": "^3.0.7",
|
||||
"slice-ansi": "^9.0.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
"string-width": "^8.2.0",
|
||||
"terminal-size": "^4.0.1",
|
||||
"type-fest": "^5.5.0",
|
||||
"widest-line": "^6.0.0",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
"ws": "^8.20.0",
|
||||
"yoga-layout": "~3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=19.2.0",
|
||||
"react": ">=19.2.0",
|
||||
"react-devtools-core": ">=6.1.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-devtools-core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ink-text-input": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"type-fest": "^4.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ink": ">=5",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz",
|
||||
"integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/cli-boxes": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
|
||||
"integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.20 <19 || >=20.10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ink/node_modules/type-fest": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
|
||||
"integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ink/node_modules/wrap-ansi": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz",
|
||||
"integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.3",
|
||||
"string-width": "^8.2.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-in-ci": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
|
||||
"integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"is-in-ci": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mimic-fn": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-console": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
|
||||
"integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
|
||||
"integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"onetime": "^5.1.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
|
||||
"integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.3",
|
||||
"is-fullwidth-code-point": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-utils": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||
"integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
|
||||
"integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-hyperlinks": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
|
||||
"integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0",
|
||||
"supports-color": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/terminal-size": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz",
|
||||
"integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/usehooks-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz",
|
||||
"integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"string-width": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ui-tui/packages/hermes-ink/package.json
Normal file
54
ui-tui/packages/hermes-ink/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "@hermes/ink",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/ink-bundle.js"
|
||||
},
|
||||
"sideEffects": true,
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./text-input": {
|
||||
"types": "./text-input.d.ts",
|
||||
"import": "./text-input.js",
|
||||
"default": "./text-input.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ink-text-input": ">=6.0.0",
|
||||
"react": ">=19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.0",
|
||||
"auto-bind": "^5.0.0",
|
||||
"bidi-js": "^1.0.0",
|
||||
"chalk": "^5.4.0",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"indent-string": "^5.0.0",
|
||||
"lodash-es": "^4.17.0",
|
||||
"react": ">=19.0.0",
|
||||
"react-reconciler": "0.33.0",
|
||||
"semver": "^7.6.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"stack-utils": "^2.0.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"supports-hyperlinks": "^3.1.0",
|
||||
"type-fest": "^4.30.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
9
ui-tui/packages/hermes-ink/src/bootstrap/state.ts
Normal file
9
ui-tui/packages/hermes-ink/src/bootstrap/state.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export function flushInteractionTime(): void {}
|
||||
|
||||
export function updateLastInteractionTime(): void {}
|
||||
|
||||
export function markScrollActivity(): void {}
|
||||
|
||||
export function getIsInteractive(): boolean {
|
||||
return !!process.stdin.isTTY && !!process.stdout.isTTY
|
||||
}
|
||||
26
ui-tui/packages/hermes-ink/src/entry-exports.ts
Normal file
26
ui-tui/packages/hermes-ink/src/entry-exports.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export { default as useStderr } from './hooks/use-stderr.js'
|
||||
export { default as useStdout } from './hooks/use-stdout.js'
|
||||
export { Ansi } from './ink/Ansi.js'
|
||||
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
||||
export { default as Box } from './ink/components/Box.js'
|
||||
export { default as Link } from './ink/components/Link.js'
|
||||
export { default as Newline } from './ink/components/Newline.js'
|
||||
export { NoSelect } from './ink/components/NoSelect.js'
|
||||
export { RawAnsi } from './ink/components/RawAnsi.js'
|
||||
export { default as ScrollBox } from './ink/components/ScrollBox.js'
|
||||
export { default as Spacer } from './ink/components/Spacer.js'
|
||||
export { default as Text } from './ink/components/Text.js'
|
||||
export { default as useApp } from './ink/hooks/use-app.js'
|
||||
export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js'
|
||||
export { type RunExternalProcess, useExternalProcess, withInkSuspended } from './ink/hooks/use-external-process.js'
|
||||
export { default as useInput } from './ink/hooks/use-input.js'
|
||||
export { useHasSelection, useSelection } from './ink/hooks/use-selection.js'
|
||||
export { default as useStdin } from './ink/hooks/use-stdin.js'
|
||||
export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
||||
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
||||
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export { createRoot, default as render, renderSync } from './ink/root.js'
|
||||
export { stringWidth } from './ink/stringWidth.js'
|
||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||
15
ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts
Normal file
15
ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useMemo } from 'react'
|
||||
export type StderrHandle = {
|
||||
stderr: NodeJS.WriteStream
|
||||
write: (data: string) => boolean
|
||||
}
|
||||
|
||||
export default function useStderr(): StderrHandle {
|
||||
return useMemo(
|
||||
() => ({
|
||||
stderr: process.stderr,
|
||||
write: data => process.stderr.write(data)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
15
ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts
Normal file
15
ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useMemo } from 'react'
|
||||
export type StdoutHandle = {
|
||||
stdout: NodeJS.WriteStream
|
||||
write: (data: string) => boolean
|
||||
}
|
||||
|
||||
export default function useStdout(): StdoutHandle {
|
||||
return useMemo(
|
||||
() => ({
|
||||
stdout: process.stdout,
|
||||
write: data => process.stdout.write(data)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
435
ui-tui/packages/hermes-ink/src/ink/Ansi.tsx
Normal file
435
ui-tui/packages/hermes-ink/src/ink/Ansi.tsx
Normal file
File diff suppressed because one or more lines are too long
145
ui-tui/packages/hermes-ink/src/ink/bidi.ts
Normal file
145
ui-tui/packages/hermes-ink/src/ink/bidi.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Bidirectional text reordering for terminal rendering.
|
||||
*
|
||||
* Terminals on Windows do not implement the Unicode Bidi Algorithm,
|
||||
* so RTL text (Hebrew, Arabic, etc.) appears reversed. This module
|
||||
* applies the bidi algorithm to reorder ClusteredChar arrays from
|
||||
* logical order to visual order before Ink's LTR cell placement loop.
|
||||
*
|
||||
* On macOS terminals (Terminal.app, iTerm2) bidi works natively.
|
||||
* Windows Terminal (including WSL) does not implement bidi
|
||||
* (https://github.com/microsoft/terminal/issues/538).
|
||||
*
|
||||
* Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost
|
||||
* also lacks bidi. We enable bidi reordering when running on Windows or
|
||||
* inside Windows Terminal (covers WSL).
|
||||
*/
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
type ClusteredChar = {
|
||||
value: string
|
||||
width: number
|
||||
styleId: number
|
||||
hyperlink: string | undefined
|
||||
}
|
||||
|
||||
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
|
||||
let needsSoftwareBidi: boolean | undefined
|
||||
|
||||
function needsBidi(): boolean {
|
||||
if (needsSoftwareBidi === undefined) {
|
||||
needsSoftwareBidi =
|
||||
process.platform === 'win32' ||
|
||||
typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal
|
||||
process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js)
|
||||
}
|
||||
|
||||
return needsSoftwareBidi
|
||||
}
|
||||
|
||||
function getBidi() {
|
||||
if (!bidiInstance) {
|
||||
bidiInstance = bidiFactory()
|
||||
}
|
||||
|
||||
return bidiInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder an array of ClusteredChars from logical order to visual order
|
||||
* using the Unicode Bidi Algorithm. Active on terminals that lack native
|
||||
* bidi support (Windows Terminal, conhost, WSL).
|
||||
*
|
||||
* Returns the same array on bidi-capable terminals (no-op).
|
||||
*/
|
||||
export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] {
|
||||
if (!needsBidi() || characters.length === 0) {
|
||||
return characters
|
||||
}
|
||||
|
||||
// Build a plain string from the clustered chars to run through bidi
|
||||
const plainText = characters.map(c => c.value).join('')
|
||||
|
||||
// Check if there are any RTL characters — skip bidi if pure LTR
|
||||
if (!hasRTLCharacters(plainText)) {
|
||||
return characters
|
||||
}
|
||||
|
||||
const bidi = getBidi()
|
||||
const { levels } = bidi.getEmbeddingLevels(plainText, 'auto')
|
||||
|
||||
// Map bidi levels back to ClusteredChar indices.
|
||||
// Each ClusteredChar may be multiple code units in the joined string.
|
||||
const charLevels: number[] = []
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0; i < characters.length; i++) {
|
||||
charLevels.push(levels[offset]!)
|
||||
offset += characters[i]!.value.length
|
||||
}
|
||||
|
||||
// Get reorder segments from bidi-js, but we need to work at the
|
||||
// ClusteredChar level, not the string level. We'll implement the
|
||||
// standard bidi reordering: find the max level, then for each level
|
||||
// from max down to 1, reverse all contiguous runs >= that level.
|
||||
const reordered = [...characters]
|
||||
const maxLevel = Math.max(...charLevels)
|
||||
|
||||
for (let level = maxLevel; level >= 1; level--) {
|
||||
let i = 0
|
||||
|
||||
while (i < reordered.length) {
|
||||
if (charLevels[i]! >= level) {
|
||||
// Find the end of this run
|
||||
let j = i + 1
|
||||
|
||||
while (j < reordered.length && charLevels[j]! >= level) {
|
||||
j++
|
||||
}
|
||||
|
||||
// Reverse the run in both arrays
|
||||
reverseRange(reordered, i, j - 1)
|
||||
reverseRangeNumbers(charLevels, i, j - 1)
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reordered
|
||||
}
|
||||
|
||||
function reverseRange<T>(arr: T[], start: number, end: number): void {
|
||||
while (start < end) {
|
||||
const temp = arr[start]!
|
||||
arr[start] = arr[end]!
|
||||
arr[end] = temp
|
||||
start++
|
||||
end--
|
||||
}
|
||||
}
|
||||
|
||||
function reverseRangeNumbers(arr: number[], start: number, end: number): void {
|
||||
while (start < end) {
|
||||
const temp = arr[start]!
|
||||
arr[start] = arr[end]!
|
||||
arr[end] = temp
|
||||
start++
|
||||
end--
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check for RTL characters (Hebrew, Arabic, and related scripts).
|
||||
* Avoids running the full bidi algorithm on pure-LTR text.
|
||||
*/
|
||||
function hasRTLCharacters(text: string): boolean {
|
||||
// Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F
|
||||
// Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF
|
||||
// Thaana: U+0780-U+07BF
|
||||
// Syriac: U+0700-U+074F
|
||||
return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test(
|
||||
text
|
||||
)
|
||||
}
|
||||
68
ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts
Normal file
68
ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Cross-platform terminal clearing with scrollback support.
|
||||
* Detects modern terminals that support ESC[3J for clearing scrollback.
|
||||
*/
|
||||
|
||||
import { csi, CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from './termio/csi.js'
|
||||
|
||||
// HVP (Horizontal Vertical Position) - legacy Windows cursor home
|
||||
const CURSOR_HOME_WINDOWS = csi(0, 'f')
|
||||
|
||||
function isWindowsTerminal(): boolean {
|
||||
return process.platform === 'win32' && !!process.env.WT_SESSION
|
||||
}
|
||||
|
||||
function isMintty(): boolean {
|
||||
// mintty 3.1.5+ sets TERM_PROGRAM to 'mintty'
|
||||
if (process.env.TERM_PROGRAM === 'mintty') {
|
||||
return true
|
||||
}
|
||||
|
||||
// GitBash/MSYS2/MINGW use mintty and set MSYSTEM
|
||||
if (process.platform === 'win32' && process.env.MSYSTEM) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isModernWindowsTerminal(): boolean {
|
||||
// Windows Terminal sets WT_SESSION environment variable
|
||||
if (isWindowsTerminal()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// VS Code integrated terminal on Windows with ConPTY support
|
||||
if (process.platform === 'win32' && process.env.TERM_PROGRAM === 'vscode' && process.env.TERM_PROGRAM_VERSION) {
|
||||
return true
|
||||
}
|
||||
|
||||
// mintty (GitBash/MSYS2/Cygwin) supports modern escape sequences
|
||||
if (isMintty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ANSI escape sequence to clear the terminal including scrollback.
|
||||
* Automatically detects terminal capabilities.
|
||||
*/
|
||||
export function getClearTerminalSequence(): string {
|
||||
if (process.platform === 'win32') {
|
||||
if (isModernWindowsTerminal()) {
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
} else {
|
||||
// Legacy Windows console - can't clear scrollback
|
||||
return ERASE_SCREEN + CURSOR_HOME_WINDOWS
|
||||
}
|
||||
}
|
||||
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen. On supported terminals, also clears scrollback.
|
||||
*/
|
||||
export const clearTerminal = getClearTerminalSequence()
|
||||
226
ui-tui/packages/hermes-ink/src/ink/colorize.ts
Normal file
226
ui-tui/packages/hermes-ink/src/ink/colorize.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import chalk from 'chalk'
|
||||
|
||||
import type { Color, TextStyles } from './styles.js'
|
||||
|
||||
/**
|
||||
* xterm.js (VS Code, Cursor, code-server, Coder) has supported truecolor
|
||||
* since 2017, but code-server/Coder containers often don't set
|
||||
* COLORTERM=truecolor. chalk's supports-color doesn't recognize
|
||||
* TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls
|
||||
* through to the -256color regex → level 2. At level 2, chalk.rgb()
|
||||
* downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) → idx 174
|
||||
* rgb(215,135,135) — washed-out salmon.
|
||||
*
|
||||
* Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0 —
|
||||
* those yield level 0 and are an explicit "no colors" request. Desktop VS
|
||||
* Code sets COLORTERM=truecolor itself, so this is a no-op there (already 3).
|
||||
*
|
||||
* Must run BEFORE the tmux clamp — if tmux is running inside a VS Code
|
||||
* terminal, tmux's passthrough limitation wins and we want level 2.
|
||||
*/
|
||||
function boostChalkLevelForXtermJs(): boolean {
|
||||
if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) {
|
||||
chalk.level = 3
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly,
|
||||
* but its client-side emitter only re-emits truecolor to the outer terminal if
|
||||
* the outer terminal advertises Tc/RGB capability (via terminal-overrides).
|
||||
* Default tmux config doesn't set this, so tmux emits the cell to iTerm2/etc
|
||||
* WITHOUT the bg sequence — outer terminal's buffer has bg=default → black on
|
||||
* dark profiles. Clamping to level 2 makes chalk emit 256-color (\e[48;5;Nm),
|
||||
* which tmux passes through cleanly. grey93 (255) is visually identical to
|
||||
* rgb(240,240,240).
|
||||
*
|
||||
* Users who HAVE set `terminal-overrides ,*:Tc` get a technically-unnecessary
|
||||
* downgrade, but the visual difference is imperceptible. Querying
|
||||
* `tmux show -gv terminal-overrides` to detect this would add a subprocess on
|
||||
* startup — not worth it.
|
||||
*
|
||||
* $TMUX is a pty-lifecycle env var set by tmux itself; it never comes from
|
||||
* globalSettings.env, so reading it here is correct. chalk is a singleton, so
|
||||
* this clamps ALL truecolor output (fg+bg+hex) across the entire app.
|
||||
*/
|
||||
function clampChalkLevelForTmux(): boolean {
|
||||
if (process.env.TMUX && chalk.level > 2) {
|
||||
chalk.level = 2
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Computed once at module load — terminal/tmux environment doesn't change mid-session.
|
||||
// Order matters: boost first so the tmux clamp can re-clamp if tmux is running
|
||||
// inside a VS Code terminal. Exported for debugging — tree-shaken if unused.
|
||||
export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs()
|
||||
export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux()
|
||||
|
||||
export type ColorType = 'foreground' | 'background'
|
||||
|
||||
const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/
|
||||
const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/
|
||||
|
||||
export const colorize = (str: string, color: string | undefined, type: ColorType): string => {
|
||||
if (!color) {
|
||||
return str
|
||||
}
|
||||
|
||||
if (color.startsWith('ansi:')) {
|
||||
const value = color.substring('ansi:'.length)
|
||||
|
||||
switch (value) {
|
||||
case 'black':
|
||||
return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str)
|
||||
|
||||
case 'red':
|
||||
return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str)
|
||||
|
||||
case 'green':
|
||||
return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str)
|
||||
|
||||
case 'yellow':
|
||||
return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str)
|
||||
|
||||
case 'blue':
|
||||
return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str)
|
||||
|
||||
case 'magenta':
|
||||
return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str)
|
||||
|
||||
case 'cyan':
|
||||
return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str)
|
||||
|
||||
case 'white':
|
||||
return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str)
|
||||
|
||||
case 'blackBright':
|
||||
return type === 'foreground' ? chalk.blackBright(str) : chalk.bgBlackBright(str)
|
||||
|
||||
case 'redBright':
|
||||
return type === 'foreground' ? chalk.redBright(str) : chalk.bgRedBright(str)
|
||||
|
||||
case 'greenBright':
|
||||
return type === 'foreground' ? chalk.greenBright(str) : chalk.bgGreenBright(str)
|
||||
|
||||
case 'yellowBright':
|
||||
return type === 'foreground' ? chalk.yellowBright(str) : chalk.bgYellowBright(str)
|
||||
|
||||
case 'blueBright':
|
||||
return type === 'foreground' ? chalk.blueBright(str) : chalk.bgBlueBright(str)
|
||||
|
||||
case 'magentaBright':
|
||||
return type === 'foreground' ? chalk.magentaBright(str) : chalk.bgMagentaBright(str)
|
||||
|
||||
case 'cyanBright':
|
||||
return type === 'foreground' ? chalk.cyanBright(str) : chalk.bgCyanBright(str)
|
||||
|
||||
case 'whiteBright':
|
||||
return type === 'foreground' ? chalk.whiteBright(str) : chalk.bgWhiteBright(str)
|
||||
}
|
||||
}
|
||||
|
||||
if (color.startsWith('#')) {
|
||||
return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str)
|
||||
}
|
||||
|
||||
if (color.startsWith('ansi256')) {
|
||||
const matches = ANSI_REGEX.exec(color)
|
||||
|
||||
if (!matches) {
|
||||
return str
|
||||
}
|
||||
|
||||
const value = Number(matches[1])
|
||||
|
||||
return type === 'foreground' ? chalk.ansi256(value)(str) : chalk.bgAnsi256(value)(str)
|
||||
}
|
||||
|
||||
if (color.startsWith('rgb')) {
|
||||
const matches = RGB_REGEX.exec(color)
|
||||
|
||||
if (!matches) {
|
||||
return str
|
||||
}
|
||||
|
||||
const firstValue = Number(matches[1])
|
||||
const secondValue = Number(matches[2])
|
||||
const thirdValue = Number(matches[3])
|
||||
|
||||
return type === 'foreground'
|
||||
? chalk.rgb(firstValue, secondValue, thirdValue)(str)
|
||||
: chalk.bgRgb(firstValue, secondValue, thirdValue)(str)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply TextStyles to a string using chalk.
|
||||
* This is the inverse of parsing ANSI codes - we generate them from structured styles.
|
||||
* Theme resolution happens at component layer, not here.
|
||||
*/
|
||||
export function applyTextStyles(text: string, styles: TextStyles): string {
|
||||
let result = text
|
||||
|
||||
// Apply styles in reverse order of desired nesting.
|
||||
// chalk wraps text so later calls become outer wrappers.
|
||||
// Desired order (outermost to innermost):
|
||||
// background > foreground > text modifiers
|
||||
// So we apply: text modifiers first, then foreground, then background last.
|
||||
|
||||
if (styles.inverse) {
|
||||
result = chalk.inverse(result)
|
||||
}
|
||||
|
||||
if (styles.strikethrough) {
|
||||
result = chalk.strikethrough(result)
|
||||
}
|
||||
|
||||
if (styles.underline) {
|
||||
result = chalk.underline(result)
|
||||
}
|
||||
|
||||
if (styles.italic) {
|
||||
result = chalk.italic(result)
|
||||
}
|
||||
|
||||
if (styles.bold) {
|
||||
result = chalk.bold(result)
|
||||
}
|
||||
|
||||
if (styles.dim) {
|
||||
result = chalk.dim(result)
|
||||
}
|
||||
|
||||
if (styles.color) {
|
||||
// Color is now always a raw color value (theme resolution happens at component layer)
|
||||
result = colorize(result, styles.color, 'foreground')
|
||||
}
|
||||
|
||||
if (styles.backgroundColor) {
|
||||
// backgroundColor is now always a raw color value
|
||||
result = colorize(result, styles.backgroundColor, 'background')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a raw color value to text.
|
||||
* Theme resolution should happen at component layer, not here.
|
||||
*/
|
||||
export function applyColor(text: string, color: Color | undefined): string {
|
||||
if (!color) {
|
||||
return text
|
||||
}
|
||||
|
||||
return colorize(text, color, 'foreground')
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
780
ui-tui/packages/hermes-ink/src/ink/components/App.tsx
Normal file
780
ui-tui/packages/hermes-ink/src/ink/components/App.tsx
Normal file
File diff suppressed because one or more lines are too long
20
ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts
Normal file
20
ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
export type Props = {
|
||||
/**
|
||||
* Exit (unmount) the whole Ink app.
|
||||
*/
|
||||
readonly exit: (error?: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `AppContext` is a React context, which exposes a method to manually exit the app (unmount).
|
||||
*/
|
||||
|
||||
const AppContext = createContext<Props>({
|
||||
exit() {}
|
||||
})
|
||||
|
||||
AppContext.displayName = 'InternalAppContext'
|
||||
|
||||
export default AppContext
|
||||
294
ui-tui/packages/hermes-ink/src/ink/components/Box.tsx
Normal file
294
ui-tui/packages/hermes-ink/src/ink/components/Box.tsx
Normal file
File diff suppressed because one or more lines are too long
236
ui-tui/packages/hermes-ink/src/ink/components/Button.tsx
Normal file
236
ui-tui/packages/hermes-ink/src/ink/components/Button.tsx
Normal file
File diff suppressed because one or more lines are too long
133
ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx
Normal file
133
ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,28 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
import type { DOMElement } from '../dom.js'
|
||||
|
||||
export type CursorDeclaration = {
|
||||
/** Display column (terminal cell width) within the declared node */
|
||||
readonly relativeX: number
|
||||
/** Line number within the declared node */
|
||||
readonly relativeY: number
|
||||
/** The ink-box DOMElement whose yoga layout provides the absolute origin */
|
||||
readonly node: DOMElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for the declared cursor position.
|
||||
*
|
||||
* The optional second argument makes `null` a conditional clear: the
|
||||
* declaration is only cleared if the currently-declared node matches
|
||||
* `clearIfNode`. This makes the hook safe for sibling components
|
||||
* (e.g. list items) that transfer focus among themselves — without the
|
||||
* node check, a newly-unfocused item's clear could clobber a
|
||||
* newly-focused sibling's set depending on layout-effect order.
|
||||
*/
|
||||
export type CursorDeclarationSetter = (declaration: CursorDeclaration | null, clearIfNode?: DOMElement | null) => void
|
||||
|
||||
const CursorDeclarationContext = createContext<CursorDeclarationSetter>(() => {})
|
||||
|
||||
export default CursorDeclarationContext
|
||||
130
ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx
Normal file
130
ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx
Normal file
File diff suppressed because one or more lines are too long
53
ui-tui/packages/hermes-ink/src/ink/components/Link.tsx
Normal file
53
ui-tui/packages/hermes-ink/src/ink/components/Link.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import { supportsHyperlinks } from '../supports-hyperlinks.js'
|
||||
|
||||
import Text from './Text.js'
|
||||
export type Props = {
|
||||
readonly children?: ReactNode
|
||||
readonly url: string
|
||||
readonly fallback?: ReactNode
|
||||
}
|
||||
|
||||
export default function Link(t0: Props) {
|
||||
const $ = _c(5)
|
||||
|
||||
const { children, url, fallback } = t0
|
||||
|
||||
const content = children ?? url
|
||||
|
||||
if (supportsHyperlinks()) {
|
||||
let t1
|
||||
|
||||
if ($[0] !== content || $[1] !== url) {
|
||||
t1 = (
|
||||
<Text>
|
||||
<ink-link href={url}>{content}</ink-link>
|
||||
</Text>
|
||||
)
|
||||
$[0] = content
|
||||
$[1] = url
|
||||
$[2] = t1
|
||||
} else {
|
||||
t1 = $[2]
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
|
||||
const t1 = fallback ?? content
|
||||
let t2
|
||||
|
||||
if ($[3] !== t1) {
|
||||
t2 = <Text>{t1}</Text>
|
||||
$[3] = t1
|
||||
$[4] = t2
|
||||
} else {
|
||||
t2 = $[4]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsInN1cHBvcnRzSHlwZXJsaW5rcyIsIlRleHQiLCJQcm9wcyIsImNoaWxkcmVuIiwidXJsIiwiZmFsbGJhY2siLCJMaW5rIiwidDAiLCIkIiwiX2MiLCJjb250ZW50IiwidDEiLCJ0MiJdLCJzb3VyY2VzIjpbIkxpbmsudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi9zdXBwb3J0cy1oeXBlcmxpbmtzLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9UZXh0LmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IHtcbiAgcmVhZG9ubHkgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgdXJsOiBzdHJpbmdcbiAgcmVhZG9ubHkgZmFsbGJhY2s/OiBSZWFjdE5vZGVcbn1cblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTGluayh7XG4gIGNoaWxkcmVuLFxuICB1cmwsXG4gIGZhbGxiYWNrLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICAvLyBVc2UgY2hpbGRyZW4gaWYgcHJvdmlkZWQsIG90aGVyd2lzZSBkaXNwbGF5IHRoZSBVUkxcbiAgY29uc3QgY29udGVudCA9IGNoaWxkcmVuID8/IHVybFxuXG4gIGlmIChzdXBwb3J0c0h5cGVybGlua3MoKSkge1xuICAgIC8vIFdyYXAgaW4gVGV4dCB0byBlbnN1cmUgd2UncmUgaW4gYSB0ZXh0IGNvbnRleHRcbiAgICAvLyAoaW5rLWxpbmsgaXMgYSB0ZXh0IGVsZW1lbnQgbGlrZSBpbmstdGV4dClcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQ+XG4gICAgICAgIDxpbmstbGluayBocmVmPXt1cmx9Pntjb250ZW50fTwvaW5rLWxpbms+XG4gICAgICA8L1RleHQ+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxUZXh0PntmYWxsYmFjayA/PyBjb250ZW50fTwvVGV4dD5cbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLGNBQWNBLFNBQVMsUUFBUSxPQUFPO0FBQ3RDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLGtCQUFrQixRQUFRLDJCQUEyQjtBQUM5RCxPQUFPQyxJQUFJLE1BQU0sV0FBVztBQUU1QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQixTQUFTQyxRQUFRLENBQUMsRUFBRUwsU0FBUztFQUM3QixTQUFTTSxHQUFHLEVBQUUsTUFBTTtFQUNwQixTQUFTQyxRQUFRLENBQUMsRUFBRVAsU0FBUztBQUMvQixDQUFDO0FBRUQsZUFBZSxTQUFBUSxLQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQWM7SUFBQU4sUUFBQTtJQUFBQyxHQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFJckI7RUFFTixNQUFBRyxPQUFBLEdBQWdCUCxRQUFlLElBQWZDLEdBQWU7RUFFL0IsSUFBSUosa0JBQWtCLENBQUMsQ0FBQztJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBSCxDQUFBLFFBQUFFLE9BQUEsSUFBQUYsQ0FBQSxRQUFBSixHQUFBO01BSXBCTyxFQUFBLElBQUMsSUFBSSxDQUNILFNBQXlDLENBQXpCUCxJQUFHLENBQUhBLElBQUUsQ0FBQyxDQUFHTSxRQUFNLENBQUUsRUFBOUIsUUFBeUMsQ0FDM0MsRUFGQyxJQUFJLENBRUU7TUFBQUYsQ0FBQSxNQUFBRSxPQUFBO01BQUFGLENBQUEsTUFBQUosR0FBQTtNQUFBSSxDQUFBLE1BQUFHLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFILENBQUE7SUFBQTtJQUFBLE9BRlBHLEVBRU87RUFBQTtFQUlHLE1BQUFBLEVBQUEsR0FBQU4sUUFBbUIsSUFBbkJLLE9BQW1CO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFKLENBQUEsUUFBQUcsRUFBQTtJQUExQkMsRUFBQSxJQUFDLElBQUksQ0FBRSxDQUFBRCxFQUFrQixDQUFFLEVBQTFCLElBQUksQ0FBNkI7SUFBQUgsQ0FBQSxNQUFBRyxFQUFBO0lBQUFILENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsT0FBbENJLEVBQWtDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
43
ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx
Normal file
43
ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
export type Props = {
|
||||
/**
|
||||
* Number of newlines to insert.
|
||||
*
|
||||
* @default 1
|
||||
*/
|
||||
readonly count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more newline (\n) characters. Must be used within <Text> components.
|
||||
*/
|
||||
export default function Newline(t0: Props) {
|
||||
const $ = _c(4)
|
||||
|
||||
const { count: t1 } = t0
|
||||
|
||||
const count = t1 === undefined ? 1 : t1
|
||||
let t2
|
||||
|
||||
if ($[0] !== count) {
|
||||
t2 = '\n'.repeat(count)
|
||||
$[0] = count
|
||||
$[1] = t2
|
||||
} else {
|
||||
t2 = $[1]
|
||||
}
|
||||
|
||||
let t3
|
||||
|
||||
if ($[2] !== t2) {
|
||||
t3 = <ink-text>{t2}</ink-text>
|
||||
$[2] = t2
|
||||
$[3] = t3
|
||||
} else {
|
||||
t3 = $[3]
|
||||
}
|
||||
|
||||
return t3
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwiY291bnQiLCJOZXdsaW5lIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwicmVwZWF0IiwidDMiXSwic291cmNlcyI6WyJOZXdsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5cbmV4cG9ydCB0eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogTnVtYmVyIG9mIG5ld2xpbmVzIHRvIGluc2VydC5cbiAgICpcbiAgICogQGRlZmF1bHQgMVxuICAgKi9cbiAgcmVhZG9ubHkgY291bnQ/OiBudW1iZXJcbn1cblxuLyoqXG4gKiBBZGRzIG9uZSBvciBtb3JlIG5ld2xpbmUgKFxcbikgY2hhcmFjdGVycy4gTXVzdCBiZSB1c2VkIHdpdGhpbiA8VGV4dD4gY29tcG9uZW50cy5cbiAqL1xuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTmV3bGluZSh7IGNvdW50ID0gMSB9OiBQcm9wcykge1xuICByZXR1cm4gPGluay10ZXh0PnsnXFxuJy5yZXBlYXQoY291bnQpfTwvaW5rLXRleHQ+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUUsTUFBTTtBQUN6QixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBLGVBQWUsU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBSixLQUFBLEVBQUFLO0VBQUEsSUFBQUgsRUFBb0I7RUFBbEIsTUFBQUYsS0FBQSxHQUFBSyxFQUFTLEtBQVRDLFNBQVMsR0FBVCxDQUFTLEdBQVRELEVBQVM7RUFBQSxJQUFBRSxFQUFBO0VBQUEsSUFBQUosQ0FBQSxRQUFBSCxLQUFBO0lBQ3ZCTyxFQUFBLE9BQUksQ0FBQUMsTUFBTyxDQUFDUixLQUFLLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsSUFBQU0sRUFBQTtFQUFBLElBQUFOLENBQUEsUUFBQUksRUFBQTtJQUE3QkUsRUFBQSxZQUF5QyxDQUE5QixDQUFBRixFQUFpQixDQUFFLEVBQTlCLFFBQXlDO0lBQUFKLENBQUEsTUFBQUksRUFBQTtJQUFBSixDQUFBLE1BQUFNLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFOLENBQUE7RUFBQTtFQUFBLE9BQXpDTSxFQUF5QztBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
73
ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx
Normal file
73
ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import Box, { type Props as BoxProps } from './Box.js'
|
||||
type Props = Omit<BoxProps, 'noSelect'> & {
|
||||
/**
|
||||
* Extend the exclusion zone from column 0 to this box's right edge,
|
||||
* for every row this box occupies. Use for gutters rendered inside a
|
||||
* wider indented container (e.g. a diff inside a tool message row):
|
||||
* without this, a multi-row drag picks up the container's leading
|
||||
* indent on rows below the prefix.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
fromLeftEdge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks its contents as non-selectable in fullscreen text selection.
|
||||
* Cells inside this box are skipped by both the selection highlight and
|
||||
* the copied text — the gutter stays visually unchanged while the user
|
||||
* drags, making it clear what will be copied.
|
||||
*
|
||||
* Use to fence off gutters (line numbers, diff +/- sigils, list bullets)
|
||||
* so click-drag over rendered code yields clean pasteable content:
|
||||
*
|
||||
* <Box flexDirection="row">
|
||||
* <NoSelect fromLeftEdge><Text dimColor> 42 +</Text></NoSelect>
|
||||
* <Text>const x = 1</Text>
|
||||
* </Box>
|
||||
*
|
||||
* Only affects alt-screen text selection (<AlternateScreen> with mouse
|
||||
* tracking). No-op in the main-screen scrollback render where the
|
||||
* terminal's native selection is used instead.
|
||||
*/
|
||||
export function NoSelect(t0: Props) {
|
||||
const $ = _c(8)
|
||||
let boxProps
|
||||
let children
|
||||
let fromLeftEdge
|
||||
|
||||
if ($[0] !== t0) {
|
||||
;({ children, fromLeftEdge, ...boxProps } = t0)
|
||||
$[0] = t0
|
||||
$[1] = boxProps
|
||||
$[2] = children
|
||||
$[3] = fromLeftEdge
|
||||
} else {
|
||||
boxProps = $[1]
|
||||
children = $[2]
|
||||
fromLeftEdge = $[3]
|
||||
}
|
||||
|
||||
const t1 = fromLeftEdge ? 'from-left-edge' : true
|
||||
let t2
|
||||
|
||||
if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) {
|
||||
t2 = (
|
||||
<Box {...boxProps} noSelect={t1}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
$[4] = boxProps
|
||||
$[5] = children
|
||||
$[6] = t1
|
||||
$[7] = t2
|
||||
} else {
|
||||
t2 = $[7]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiQm94IiwiUHJvcHMiLCJCb3hQcm9wcyIsIk9taXQiLCJmcm9tTGVmdEVkZ2UiLCJOb1NlbGVjdCIsInQwIiwiJCIsIl9jIiwiYm94UHJvcHMiLCJjaGlsZHJlbiIsInQxIiwidDIiXSwic291cmNlcyI6WyJOb1NlbGVjdC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4gfSBmcm9tICdyZWFjdCdcbmltcG9ydCBCb3gsIHsgdHlwZSBQcm9wcyBhcyBCb3hQcm9wcyB9IGZyb20gJy4vQm94LmpzJ1xuXG50eXBlIFByb3BzID0gT21pdDxCb3hQcm9wcywgJ25vU2VsZWN0Jz4gJiB7XG4gIC8qKlxuICAgKiBFeHRlbmQgdGhlIGV4Y2x1c2lvbiB6b25lIGZyb20gY29sdW1uIDAgdG8gdGhpcyBib3gncyByaWdodCBlZGdlLFxuICAgKiBmb3IgZXZlcnkgcm93IHRoaXMgYm94IG9jY3VwaWVzLiBVc2UgZm9yIGd1dHRlcnMgcmVuZGVyZWQgaW5zaWRlIGFcbiAgICogd2lkZXIgaW5kZW50ZWQgY29udGFpbmVyIChlLmcuIGEgZGlmZiBpbnNpZGUgYSB0b29sIG1lc3NhZ2Ugcm93KTpcbiAgICogd2l0aG91dCB0aGlzLCBhIG11bHRpLXJvdyBkcmFnIHBpY2tzIHVwIHRoZSBjb250YWluZXIncyBsZWFkaW5nXG4gICAqIGluZGVudCBvbiByb3dzIGJlbG93IHRoZSBwcmVmaXguXG4gICAqXG4gICAqIEBkZWZhdWx0IGZhbHNlXG4gICAqL1xuICBmcm9tTGVmdEVkZ2U/OiBib29sZWFuXG59XG5cbi8qKlxuICogTWFya3MgaXRzIGNvbnRlbnRzIGFzIG5vbi1zZWxlY3RhYmxlIGluIGZ1bGxzY3JlZW4gdGV4dCBzZWxlY3Rpb24uXG4gKiBDZWxscyBpbnNpZGUgdGhpcyBib3ggYXJlIHNraXBwZWQgYnkgYm90aCB0aGUgc2VsZWN0aW9uIGhpZ2hsaWdodCBhbmRcbiAqIHRoZSBjb3BpZWQgdGV4dCDigJQgdGhlIGd1dHRlciBzdGF5cyB2aXN1YWxseSB1bmNoYW5nZWQgd2hpbGUgdGhlIHVzZXJcbiAqIGRyYWdzLCBtYWtpbmcgaXQgY2xlYXIgd2hhdCB3aWxsIGJlIGNvcGllZC5cbiAqXG4gKiBVc2UgdG8gZmVuY2Ugb2ZmIGd1dHRlcnMgKGxpbmUgbnVtYmVycywgZGlmZiArLy0gc2lnaWxzLCBsaXN0IGJ1bGxldHMpXG4gKiBzbyBjbGljay1kcmFnIG92ZXIgcmVuZGVyZWQgY29kZSB5aWVsZHMgY2xlYW4gcGFzdGVhYmxlIGNvbnRlbnQ6XG4gKlxuICogICA8Qm94IGZsZXhEaXJlY3Rpb249XCJyb3dcIj5cbiAqICAgICA8Tm9TZWxlY3QgZnJvbUxlZnRFZGdlPjxUZXh0IGRpbUNvbG9yPiA0MiArPC9UZXh0PjwvTm9TZWxlY3Q+XG4gKiAgICAgPFRleHQ+Y29uc3QgeCA9IDE8L1RleHQ+XG4gKiAgIDwvQm94PlxuICpcbiAqIE9ubHkgYWZmZWN0cyBhbHQtc2NyZWVuIHRleHQgc2VsZWN0aW9uICg8QWx0ZXJuYXRlU2NyZWVuPiB3aXRoIG1vdXNlXG4gKiB0cmFja2luZykuIE5vLW9wIGluIHRoZSBtYWluLXNjcmVlbiBzY3JvbGxiYWNrIHJlbmRlciB3aGVyZSB0aGVcbiAqIHRlcm1pbmFsJ3MgbmF0aXZlIHNlbGVjdGlvbiBpcyB1c2VkIGluc3RlYWQuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBOb1NlbGVjdCh7XG4gIGNoaWxkcmVuLFxuICBmcm9tTGVmdEVkZ2UsXG4gIC4uLmJveFByb3BzXG59OiBQcm9wc1dpdGhDaGlsZHJlbjxQcm9wcz4pOiBSZWFjdC5SZWFjdE5vZGUge1xuICByZXR1cm4gKFxuICAgIDxCb3ggey4uLmJveFByb3BzfSBub1NlbGVjdD17ZnJvbUxlZnRFZGdlID8gJ2Zyb20tbGVmdC1lZGdlJyA6IHRydWV9PlxuICAgICAge2NoaWxkcmVufVxuICAgIDwvQm94PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLFFBQVEsT0FBTztBQUNyRCxPQUFPQyxHQUFHLElBQUksS0FBS0MsS0FBSyxJQUFJQyxRQUFRLFFBQVEsVUFBVTtBQUV0RCxLQUFLRCxLQUFLLEdBQUdFLElBQUksQ0FBQ0QsUUFBUSxFQUFFLFVBQVUsQ0FBQyxHQUFHO0VBQ3hDO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFRSxZQUFZLENBQUMsRUFBRSxPQUFPO0FBQ3hCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxTQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQUEsSUFBQUMsUUFBQTtFQUFBLElBQUFDLFFBQUE7RUFBQSxJQUFBTixZQUFBO0VBQUEsSUFBQUcsQ0FBQSxRQUFBRCxFQUFBO0lBQWtCO01BQUFJLFFBQUE7TUFBQU4sWUFBQTtNQUFBLEdBQUFLO0lBQUEsSUFBQUgsRUFJRTtJQUFBQyxDQUFBLE1BQUFELEVBQUE7SUFBQUMsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsUUFBQTtJQUFBSCxDQUFBLE1BQUFILFlBQUE7RUFBQTtJQUFBSyxRQUFBLEdBQUFGLENBQUE7SUFBQUcsUUFBQSxHQUFBSCxDQUFBO0lBQUFILFlBQUEsR0FBQUcsQ0FBQTtFQUFBO0VBRU0sTUFBQUksRUFBQSxHQUFBUCxZQUFZLEdBQVosZ0JBQXNDLEdBQXRDLElBQXNDO0VBQUEsSUFBQVEsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFHLFFBQUEsSUFBQUgsQ0FBQSxRQUFBSSxFQUFBO0lBQW5FQyxFQUFBLElBQUMsR0FBRyxLQUFLSCxRQUFRLEVBQVksUUFBc0MsQ0FBdEMsQ0FBQUUsRUFBcUMsQ0FBQyxDQUNoRUQsU0FBTyxDQUNWLEVBRkMsR0FBRyxDQUVFO0lBQUFILENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFHLFFBQUE7SUFBQUgsQ0FBQSxNQUFBSSxFQUFBO0lBQUFKLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FGTkssRUFFTTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
61
ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx
Normal file
61
ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
type Props = {
|
||||
/**
|
||||
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
|
||||
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
|
||||
*/
|
||||
lines: string[]
|
||||
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
|
||||
width: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
|
||||
* content that is already terminal-ready.
|
||||
*
|
||||
* Use this when an external renderer (e.g. the ColorDiff NAPI module) has
|
||||
* already produced ANSI-escaped, width-wrapped output. A normal <Ansi> mount
|
||||
* reparses that output into one React <Text> per style span, lays out each
|
||||
* span as a Yoga flex child, then walks the tree to re-emit the same escape
|
||||
* codes it was given. For a long transcript full of syntax-highlighted diffs
|
||||
* that roundtrip is the dominant cost of the render.
|
||||
*
|
||||
* This component emits a single Yoga leaf with a constant-time measure func
|
||||
* (width × lines.length) and hands the joined string straight to output.write(),
|
||||
* which already splits on '\n' and parses ANSI into the screen buffer.
|
||||
*/
|
||||
export function RawAnsi(t0: Props) {
|
||||
const $ = _c(6)
|
||||
|
||||
const { lines, width } = t0
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let t1
|
||||
|
||||
if ($[0] !== lines) {
|
||||
t1 = lines.join('\n')
|
||||
$[0] = lines
|
||||
$[1] = t1
|
||||
} else {
|
||||
t1 = $[1]
|
||||
}
|
||||
|
||||
let t2
|
||||
|
||||
if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) {
|
||||
t2 = <ink-raw-ansi rawHeight={lines.length} rawText={t1} rawWidth={width} />
|
||||
$[2] = lines.length
|
||||
$[3] = t1
|
||||
$[4] = width
|
||||
$[5] = t2
|
||||
} else {
|
||||
t2 = $[5]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwibGluZXMiLCJ3aWR0aCIsIlJhd0Fuc2kiLCJ0MCIsIiQiLCJfYyIsImxlbmd0aCIsInQxIiwiam9pbiIsInQyIl0sInNvdXJjZXMiOlsiUmF3QW5zaS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuXG50eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogUHJlLXJlbmRlcmVkIEFOU0kgbGluZXMuIEVhY2ggZWxlbWVudCBtdXN0IGJlIGV4YWN0bHkgb25lIHRlcm1pbmFsIHJvd1xuICAgKiAoYWxyZWFkeSB3cmFwcGVkIHRvIGB3aWR0aGAgYnkgdGhlIHByb2R1Y2VyKSB3aXRoIEFOU0kgZXNjYXBlIGNvZGVzIGlubGluZS5cbiAgICovXG4gIGxpbmVzOiBzdHJpbmdbXVxuICAvKiogQ29sdW1uIHdpZHRoIHRoZSBwcm9kdWNlciB3cmFwcGVkIHRvLiBTZW50IHRvIFlvZ2EgYXMgdGhlIGZpeGVkIGxlYWYgd2lkdGguICovXG4gIHdpZHRoOiBudW1iZXJcbn1cblxuLyoqXG4gKiBCeXBhc3MgdGhlIDxBbnNpPiDihpIgUmVhY3QgdHJlZSDihpIgWW9nYSDihpIgc3F1YXNoIOKGkiByZS1zZXJpYWxpemUgcm91bmR0cmlwIGZvclxuICogY29udGVudCB0aGF0IGlzIGFscmVhZHkgdGVybWluYWwtcmVhZHkuXG4gKlxuICogVXNlIHRoaXMgd2hlbiBhbiBleHRlcm5hbCByZW5kZXJlciAoZS5nLiB0aGUgQ29sb3JEaWZmIE5BUEkgbW9kdWxlKSBoYXNcbiAqIGFscmVhZHkgcHJvZHVjZWQgQU5TSS1lc2NhcGVkLCB3aWR0aC13cmFwcGVkIG91dHB1dC4gQSBub3JtYWwgPEFuc2k+IG1vdW50XG4gKiByZXBhcnNlcyB0aGF0IG91dHB1dCBpbnRvIG9uZSBSZWFjdCA8VGV4dD4gcGVyIHN0eWxlIHNwYW4sIGxheXMgb3V0IGVhY2hcbiAqIHNwYW4gYXMgYSBZb2dhIGZsZXggY2hpbGQsIHRoZW4gd2Fsa3MgdGhlIHRyZWUgdG8gcmUtZW1pdCB0aGUgc2FtZSBlc2NhcGVcbiAqIGNvZGVzIGl0IHdhcyBnaXZlbi4gRm9yIGEgbG9uZyB0cmFuc2NyaXB0IGZ1bGwgb2Ygc3ludGF4LWhpZ2hsaWdodGVkIGRpZmZzXG4gKiB0aGF0IHJvdW5kdHJpcCBpcyB0aGUgZG9taW5hbnQgY29zdCBvZiB0aGUgcmVuZGVyLlxuICpcbiAqIFRoaXMgY29tcG9uZW50IGVtaXRzIGEgc2luZ2xlIFlvZ2EgbGVhZiB3aXRoIGEgY29uc3RhbnQtdGltZSBtZWFzdXJlIGZ1bmNcbiAqICh3aWR0aCDDlyBsaW5lcy5sZW5ndGgpIGFuZCBoYW5kcyB0aGUgam9pbmVkIHN0cmluZyBzdHJhaWdodCB0byBvdXRwdXQud3JpdGUoKSxcbiAqIHdoaWNoIGFscmVhZHkgc3BsaXRzIG9uICdcXG4nIGFuZCBwYXJzZXMgQU5TSSBpbnRvIHRoZSBzY3JlZW4gYnVmZmVyLlxuICovXG5leHBvcnQgZnVuY3Rpb24gUmF3QW5zaSh7IGxpbmVzLCB3aWR0aCB9OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGlmIChsaW5lcy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG4gIHJldHVybiAoXG4gICAgPGluay1yYXctYW5zaVxuICAgICAgcmF3VGV4dD17bGluZXMuam9pbignXFxuJyl9XG4gICAgICByYXdXaWR0aD17d2lkdGh9XG4gICAgICByYXdIZWlnaHQ9e2xpbmVzLmxlbmd0aH1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixLQUFLQyxLQUFLLEdBQUc7RUFDWDtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxLQUFLLEVBQUUsTUFBTSxFQUFFO0VBQ2Y7RUFDQUMsS0FBSyxFQUFFLE1BQU07QUFDZixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8sU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBTCxLQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFBdUI7RUFDN0MsSUFBSUgsS0FBSyxDQUFBTSxNQUFPLEtBQUssQ0FBQztJQUFBLE9BQ2IsSUFBSTtFQUFBO0VBQ1osSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUosS0FBQTtJQUdZTyxFQUFBLEdBQUFQLEtBQUssQ0FBQVEsSUFBSyxDQUFDLElBQUksQ0FBQztJQUFBSixDQUFBLE1BQUFKLEtBQUE7SUFBQUksQ0FBQSxNQUFBRyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBSCxDQUFBO0VBQUE7RUFBQSxJQUFBSyxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBSixLQUFBLENBQUFNLE1BQUEsSUFBQUYsQ0FBQSxRQUFBRyxFQUFBLElBQUFILENBQUEsUUFBQUgsS0FBQTtJQUQzQlEsRUFBQSxnQkFJRSxDQUhTLE9BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNmTixRQUFLLENBQUxBLE1BQUksQ0FBQyxDQUNKLFNBQVksQ0FBWixDQUFBRCxLQUFLLENBQUFNLE1BQU0sQ0FBQyxHQUN2QjtJQUFBRixDQUFBLE1BQUFKLEtBQUEsQ0FBQU0sTUFBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7SUFBQUgsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FKRkssRUFJRTtBQUFBIiwiaWdub3JlTGlzdCI6W119
|
||||
285
ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx
Normal file
285
ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx
Normal file
File diff suppressed because one or more lines are too long
23
ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx
Normal file
23
ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import Box from './Box.js'
|
||||
|
||||
/**
|
||||
* A flexible space that expands along the major axis of its containing layout.
|
||||
* It's useful as a shortcut for filling all the available spaces between elements.
|
||||
*/
|
||||
export default function Spacer() {
|
||||
const $ = _c(1)
|
||||
let t0
|
||||
|
||||
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
|
||||
t0 = <Box flexGrow={1} />
|
||||
$[0] = t0
|
||||
} else {
|
||||
t0 = $[0]
|
||||
}
|
||||
|
||||
return t0
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlNwYWNlciIsIiQiLCJfYyIsInQwIiwiU3ltYm9sIiwiZm9yIl0sInNvdXJjZXMiOlsiU3BhY2VyLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG4vKipcbiAqIEEgZmxleGlibGUgc3BhY2UgdGhhdCBleHBhbmRzIGFsb25nIHRoZSBtYWpvciBheGlzIG9mIGl0cyBjb250YWluaW5nIGxheW91dC5cbiAqIEl0J3MgdXNlZnVsIGFzIGEgc2hvcnRjdXQgZm9yIGZpbGxpbmcgYWxsIHRoZSBhdmFpbGFibGUgc3BhY2VzIGJldHdlZW4gZWxlbWVudHMuXG4gKi9cbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIFNwYWNlcigpIHtcbiAgcmV0dXJuIDxCb3ggZmxleEdyb3c9ezF9IC8+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixPQUFPQyxHQUFHLE1BQU0sVUFBVTs7QUFFMUI7QUFDQTtBQUNBO0FBQ0E7QUFDQSxlQUFlLFNBQUFDLE9BQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUYsQ0FBQSxRQUFBRyxNQUFBLENBQUFDLEdBQUE7SUFDTkYsRUFBQSxJQUFDLEdBQUcsQ0FBVyxRQUFDLENBQUQsR0FBQyxHQUFJO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBcEJFLEVBQW9CO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
import { EventEmitter } from '../events/emitter.js'
|
||||
import type { TerminalQuerier } from '../terminal-querier.js'
|
||||
|
||||
export type Props = {
|
||||
readonly stdin: NodeJS.ReadStream
|
||||
readonly setRawMode: (value: boolean) => void
|
||||
readonly isRawModeSupported: boolean
|
||||
readonly exitOnCtrlC: boolean
|
||||
readonly inputEmitter: EventEmitter
|
||||
readonly querier: TerminalQuerier | null
|
||||
}
|
||||
|
||||
const StdinContext = createContext<Props>({
|
||||
stdin: process.stdin,
|
||||
inputEmitter: new EventEmitter(),
|
||||
setRawMode() {},
|
||||
isRawModeSupported: false,
|
||||
exitOnCtrlC: true,
|
||||
querier: null
|
||||
})
|
||||
|
||||
StdinContext.displayName = 'StdinContext'
|
||||
export default StdinContext
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import React, { createContext, type ReactNode, useSyncExternalStore } from 'react'
|
||||
import { c as _c } from 'react/compiler-runtime'
|
||||
|
||||
import {
|
||||
getTerminalFocused,
|
||||
getTerminalFocusState,
|
||||
subscribeTerminalFocus,
|
||||
type TerminalFocusState
|
||||
} from '../terminal-focus-state.js'
|
||||
export type { TerminalFocusState }
|
||||
export type TerminalFocusContextProps = {
|
||||
readonly isTerminalFocused: boolean
|
||||
readonly terminalFocusState: TerminalFocusState
|
||||
}
|
||||
|
||||
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
|
||||
isTerminalFocused: true,
|
||||
terminalFocusState: 'unknown'
|
||||
})
|
||||
|
||||
TerminalFocusContext.displayName = 'TerminalFocusContext'
|
||||
|
||||
// Separate component so App.tsx doesn't re-render on focus changes.
|
||||
// Children are a stable prop reference, so they don't re-render either —
|
||||
// only components that consume the context will re-render.
|
||||
export function TerminalFocusProvider(t0: { readonly children: ReactNode }) {
|
||||
const $ = _c(6)
|
||||
|
||||
const { children } = t0
|
||||
|
||||
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused)
|
||||
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState)
|
||||
let t1
|
||||
|
||||
if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) {
|
||||
t1 = {
|
||||
isTerminalFocused,
|
||||
terminalFocusState
|
||||
}
|
||||
$[0] = isTerminalFocused
|
||||
$[1] = terminalFocusState
|
||||
$[2] = t1
|
||||
} else {
|
||||
t1 = $[2]
|
||||
}
|
||||
|
||||
const value = t1
|
||||
let t2
|
||||
|
||||
if ($[3] !== children || $[4] !== value) {
|
||||
t2 = <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>
|
||||
$[3] = children
|
||||
$[4] = value
|
||||
$[5] = t2
|
||||
} else {
|
||||
t2 = $[5]
|
||||
}
|
||||
|
||||
return t2
|
||||
}
|
||||
|
||||
export default TerminalFocusContext
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VNZW1vIiwidXNlU3luY0V4dGVybmFsU3RvcmUiLCJnZXRUZXJtaW5hbEZvY3VzZWQiLCJnZXRUZXJtaW5hbEZvY3VzU3RhdGUiLCJzdWJzY3JpYmVUZXJtaW5hbEZvY3VzIiwiVGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHRQcm9wcyIsImlzVGVybWluYWxGb2N1c2VkIiwidGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHQiLCJkaXNwbGF5TmFtZSIsIlRlcm1pbmFsRm9jdXNQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInZhbHVlIiwidDIiXSwic291cmNlcyI6WyJUZXJtaW5hbEZvY3VzQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IGNyZWF0ZUNvbnRleHQsIHVzZU1lbW8sIHVzZVN5bmNFeHRlcm5hbFN0b3JlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICBnZXRUZXJtaW5hbEZvY3VzZWQsXG4gIGdldFRlcm1pbmFsRm9jdXNTdGF0ZSxcbiAgc3Vic2NyaWJlVGVybWluYWxGb2N1cyxcbiAgdHlwZSBUZXJtaW5hbEZvY3VzU3RhdGUsXG59IGZyb20gJy4uL3Rlcm1pbmFsLWZvY3VzLXN0YXRlLmpzJ1xuXG5leHBvcnQgdHlwZSB7IFRlcm1pbmFsRm9jdXNTdGF0ZSB9XG5cbmV4cG9ydCB0eXBlIFRlcm1pbmFsRm9jdXNDb250ZXh0UHJvcHMgPSB7XG4gIHJlYWRvbmx5IGlzVGVybWluYWxGb2N1c2VkOiBib29sZWFuXG4gIHJlYWRvbmx5IHRlcm1pbmFsRm9jdXNTdGF0ZTogVGVybWluYWxGb2N1c1N0YXRlXG59XG5cbmNvbnN0IFRlcm1pbmFsRm9jdXNDb250ZXh0ID0gY3JlYXRlQ29udGV4dDxUZXJtaW5hbEZvY3VzQ29udGV4dFByb3BzPih7XG4gIGlzVGVybWluYWxGb2N1c2VkOiB0cnVlLFxuICB0ZXJtaW5hbEZvY3VzU3RhdGU6ICd1bmtub3duJyxcbn0pXG5cbi8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tdG9wLWxldmVsLXNpZGUtZWZmZWN0c1xuVGVybWluYWxGb2N1c0NvbnRleHQuZGlzcGxheU5hbWUgPSAnVGVybWluYWxGb2N1c0NvbnRleHQnXG5cbi8vIFNlcGFyYXRlIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIG9uIGZvY3VzIGNoYW5nZXMuXG4vLyBDaGlsZHJlbiBhcmUgYSBzdGFibGUgcHJvcCByZWZlcmVuY2UsIHNvIHRoZXkgZG9uJ3QgcmUtcmVuZGVyIGVpdGhlciDigJRcbi8vIG9ubHkgY29tcG9uZW50cyB0aGF0IGNvbnN1bWUgdGhlIGNvbnRleHQgd2lsbCByZS1yZW5kZXIuXG5leHBvcnQgZnVuY3Rpb24gVGVybWluYWxGb2N1c1Byb3ZpZGVyKHtcbiAgY2hpbGRyZW4sXG59OiB7XG4gIGNoaWxkcmVuOiBSZWFjdC5SZWFjdE5vZGVcbn0pOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBpc1Rlcm1pbmFsRm9jdXNlZCA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c2VkLFxuICApXG4gIGNvbnN0IHRlcm1pbmFsRm9jdXNTdGF0ZSA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c1N0YXRlLFxuICApXG5cbiAgY29uc3QgdmFsdWUgPSB1c2VNZW1vKFxuICAgICgpID0+ICh7IGlzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGUgfSksXG4gICAgW2lzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGVdLFxuICApXG5cbiAgcmV0dXJuIChcbiAgICA8VGVybWluYWxGb2N1c0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e3ZhbHVlfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L1Rlcm1pbmFsRm9jdXNDb250ZXh0LlByb3ZpZGVyPlxuICApXG59XG5cbmV4cG9ydCBkZWZhdWx0IFRlcm1pbmFsRm9jdXNDb250ZXh0XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsT0FBTyxFQUFFQyxvQkFBb0IsUUFBUSxPQUFPO0FBQzNFLFNBQ0VDLGtCQUFrQixFQUNsQkMscUJBQXFCLEVBQ3JCQyxzQkFBc0IsRUFDdEIsS0FBS0Msa0JBQWtCLFFBQ2xCLDRCQUE0QjtBQUVuQyxjQUFjQSxrQkFBa0I7QUFFaEMsT0FBTyxLQUFLQyx5QkFBeUIsR0FBRztFQUN0QyxTQUFTQyxpQkFBaUIsRUFBRSxPQUFPO0VBQ25DLFNBQVNDLGtCQUFrQixFQUFFSCxrQkFBa0I7QUFDakQsQ0FBQztBQUVELE1BQU1JLG9CQUFvQixHQUFHVixhQUFhLENBQUNPLHlCQUF5QixDQUFDLENBQUM7RUFDcEVDLGlCQUFpQixFQUFFLElBQUk7RUFDdkJDLGtCQUFrQixFQUFFO0FBQ3RCLENBQUMsQ0FBQzs7QUFFRjtBQUNBQyxvQkFBb0IsQ0FBQ0MsV0FBVyxHQUFHLHNCQUFzQjs7QUFFekQ7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxzQkFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUErQjtJQUFBQztFQUFBLElBQUFILEVBSXJDO0VBQ0MsTUFBQUwsaUJBQUEsR0FBMEJOLG9CQUFvQixDQUM1Q0csc0JBQXNCLEVBQ3RCRixrQkFDRixDQUFDO0VBQ0QsTUFBQU0sa0JBQUEsR0FBMkJQLG9CQUFvQixDQUM3Q0csc0JBQXNCLEVBQ3RCRCxxQkFDRixDQUFDO0VBQUEsSUFBQWEsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQU4saUJBQUEsSUFBQU0sQ0FBQSxRQUFBTCxrQkFBQTtJQUdRUSxFQUFBO01BQUFULGlCQUFBO01BQUFDO0lBQXdDLENBQUM7SUFBQUssQ0FBQSxNQUFBTixpQkFBQTtJQUFBTSxDQUFBLE1BQUFMLGtCQUFBO0lBQUFLLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBRGxELE1BQUFJLEtBQUEsR0FDU0QsRUFBeUM7RUFFakQsSUFBQUUsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFJLEtBQUE7SUFHQ0MsRUFBQSxrQ0FBc0NELEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQ3hDRixTQUFPLENBQ1YsZ0NBQWdDO0lBQUFGLENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFJLEtBQUE7SUFBQUosQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBTCxDQUFBO0VBQUE7RUFBQSxPQUZoQ0ssRUFFZ0M7QUFBQTtBQUlwQyxlQUFlVCxvQkFBb0IiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { createContext } from 'react'
|
||||
export type TerminalSize = {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
export const TerminalSizeContext = createContext<TerminalSize | null>(null)
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjcmVhdGVDb250ZXh0IiwiVGVybWluYWxTaXplIiwiY29sdW1ucyIsInJvd3MiLCJUZXJtaW5hbFNpemVDb250ZXh0Il0sInNvdXJjZXMiOlsiVGVybWluYWxTaXplQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY3JlYXRlQ29udGV4dCB9IGZyb20gJ3JlYWN0J1xuXG5leHBvcnQgdHlwZSBUZXJtaW5hbFNpemUgPSB7XG4gIGNvbHVtbnM6IG51bWJlclxuICByb3dzOiBudW1iZXJcbn1cblxuZXhwb3J0IGNvbnN0IFRlcm1pbmFsU2l6ZUNvbnRleHQgPSBjcmVhdGVDb250ZXh0PFRlcm1pbmFsU2l6ZSB8IG51bGw+KG51bGwpXG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLGFBQWEsUUFBUSxPQUFPO0FBRXJDLE9BQU8sS0FBS0MsWUFBWSxHQUFHO0VBQ3pCQyxPQUFPLEVBQUUsTUFBTTtFQUNmQyxJQUFJLEVBQUUsTUFBTTtBQUNkLENBQUM7QUFFRCxPQUFPLE1BQU1DLG1CQUFtQixHQUFHSixhQUFhLENBQUNDLFlBQVksR0FBRyxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
296
ui-tui/packages/hermes-ink/src/ink/components/Text.tsx
Normal file
296
ui-tui/packages/hermes-ink/src/ink/components/Text.tsx
Normal file
File diff suppressed because one or more lines are too long
6
ui-tui/packages/hermes-ink/src/ink/constants.ts
Normal file
6
ui-tui/packages/hermes-ink/src/ink/constants.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Shared frame interval for render throttling and animations (~60fps).
|
||||
export const FRAME_INTERVAL_MS = 16
|
||||
|
||||
// Keep clock-driven animations at full speed when terminal focus changes.
|
||||
// We still pause entirely when there are no keepAlive subscribers.
|
||||
export const BLURRED_FRAME_INTERVAL_MS = FRAME_INTERVAL_MS
|
||||
5
ui-tui/packages/hermes-ink/src/ink/cursor.ts
Normal file
5
ui-tui/packages/hermes-ink/src/ink/cursor.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type Cursor = {
|
||||
x: number
|
||||
y: number
|
||||
visible: boolean
|
||||
}
|
||||
2
ui-tui/packages/hermes-ink/src/ink/devtools.ts
Normal file
2
ui-tui/packages/hermes-ink/src/ink/devtools.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** Optional react-devtools hook; package may be absent. */
|
||||
export {}
|
||||
438
ui-tui/packages/hermes-ink/src/ink/dom.ts
Normal file
438
ui-tui/packages/hermes-ink/src/ink/dom.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import type { FocusManager } from './focus.js'
|
||||
import { createLayoutNode } from './layout/engine.js'
|
||||
import type { LayoutNode } from './layout/node.js'
|
||||
import { LayoutMeasureMode } from './layout/node.js'
|
||||
import measureText from './measure-text.js'
|
||||
import { addPendingClear, nodeCache } from './node-cache.js'
|
||||
import squashTextNodes from './squash-text-nodes.js'
|
||||
import type { Styles, TextStyles } from './styles.js'
|
||||
import { expandTabs } from './tabstops.js'
|
||||
import wrapText from './wrap-text.js'
|
||||
|
||||
type InkNode = {
|
||||
parentNode: DOMElement | undefined
|
||||
yogaNode?: LayoutNode
|
||||
style: Styles
|
||||
}
|
||||
|
||||
export type TextName = '#text'
|
||||
export type ElementNames =
|
||||
| 'ink-root'
|
||||
| 'ink-box'
|
||||
| 'ink-text'
|
||||
| 'ink-virtual-text'
|
||||
| 'ink-link'
|
||||
| 'ink-progress'
|
||||
| 'ink-raw-ansi'
|
||||
|
||||
export type NodeNames = ElementNames | TextName
|
||||
|
||||
export type DOMElement = {
|
||||
nodeName: ElementNames
|
||||
attributes: Record<string, DOMNodeAttribute>
|
||||
childNodes: DOMNode[]
|
||||
textStyles?: TextStyles
|
||||
|
||||
// Internal properties
|
||||
onComputeLayout?: () => void
|
||||
onRender?: () => void
|
||||
onImmediateRender?: () => void
|
||||
// Used to skip empty renders during React 19's effect double-invoke in test mode
|
||||
hasRenderedContent?: boolean
|
||||
|
||||
// When true, this node needs re-rendering
|
||||
dirty: boolean
|
||||
// Set by the reconciler's hideInstance/unhideInstance; survives style updates.
|
||||
isHidden?: boolean
|
||||
// Event handlers set by the reconciler for the capture/bubble dispatcher.
|
||||
// Stored separately from attributes so handler identity changes don't
|
||||
// mark dirty and defeat the blit optimization.
|
||||
_eventHandlers?: Record<string, unknown>
|
||||
|
||||
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
|
||||
// rows the content is scrolled down by. scrollHeight/scrollViewportHeight
|
||||
// are computed at render time and stored for imperative access. stickyScroll
|
||||
// auto-pins scrollTop to the bottom when content grows.
|
||||
scrollTop?: number
|
||||
// Accumulated scroll delta not yet applied to scrollTop. The renderer
|
||||
// drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show
|
||||
// intermediate frames instead of one big jump. Direction reversal
|
||||
// naturally cancels (pure accumulator, no target tracking).
|
||||
pendingScrollDelta?: number
|
||||
// Render-time clamp bounds for virtual scroll. useVirtualScroll writes
|
||||
// the currently-mounted children's coverage span; render-node-to-output
|
||||
// clamps scrollTop to stay within it. Prevents blank screen when
|
||||
// scrollTo's direct write races past React's async re-render — instead
|
||||
// of painting spacer (blank), the renderer holds at the edge of mounted
|
||||
// content until React catches up (next commit updates these bounds and
|
||||
// the clamp releases). Undefined = no clamp (sticky-scroll, cold start).
|
||||
scrollClampMin?: number
|
||||
scrollClampMax?: number
|
||||
scrollHeight?: number
|
||||
scrollViewportHeight?: number
|
||||
scrollViewportTop?: number
|
||||
stickyScroll?: boolean
|
||||
// Set by ScrollBox.scrollToElement; render-node-to-output reads
|
||||
// el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight)
|
||||
// and sets scrollTop = top + offset, then clears this. Unlike an
|
||||
// imperative scrollTo(N) which bakes in a number that's stale by the
|
||||
// time the throttled render fires, the element ref defers the position
|
||||
// read to paint time. One-shot.
|
||||
scrollAnchor?: { el: DOMElement; offset: number }
|
||||
// Only set on ink-root. The document owns focus — any node can
|
||||
// reach it by walking parentNode, like browser getRootNode().
|
||||
focusManager?: FocusManager
|
||||
} & InkNode
|
||||
|
||||
export type TextNode = {
|
||||
nodeName: TextName
|
||||
nodeValue: string
|
||||
} & InkNode
|
||||
|
||||
export type DOMNode<T = { nodeName: NodeNames }> = T extends {
|
||||
nodeName: infer U
|
||||
}
|
||||
? U extends '#text'
|
||||
? TextNode
|
||||
: DOMElement
|
||||
: never
|
||||
|
||||
export type DOMNodeAttribute = boolean | string | number
|
||||
|
||||
export const createNode = (nodeName: ElementNames): DOMElement => {
|
||||
const needsYogaNode = nodeName !== 'ink-virtual-text' && nodeName !== 'ink-link' && nodeName !== 'ink-progress'
|
||||
|
||||
const node: DOMElement = {
|
||||
nodeName,
|
||||
style: {},
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
parentNode: undefined,
|
||||
yogaNode: needsYogaNode ? createLayoutNode() : undefined,
|
||||
dirty: false
|
||||
}
|
||||
|
||||
if (nodeName === 'ink-text') {
|
||||
node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
|
||||
} else if (nodeName === 'ink-raw-ansi') {
|
||||
node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
export const appendChildNode = (node: DOMElement, childNode: DOMElement): void => {
|
||||
if (childNode.parentNode) {
|
||||
removeChildNode(childNode.parentNode, childNode)
|
||||
}
|
||||
|
||||
childNode.parentNode = node
|
||||
node.childNodes.push(childNode)
|
||||
|
||||
if (childNode.yogaNode) {
|
||||
node.yogaNode?.insertChild(childNode.yogaNode, node.yogaNode.getChildCount())
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const insertBeforeNode = (node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode): void => {
|
||||
if (newChildNode.parentNode) {
|
||||
removeChildNode(newChildNode.parentNode, newChildNode)
|
||||
}
|
||||
|
||||
newChildNode.parentNode = node
|
||||
|
||||
const index = node.childNodes.indexOf(beforeChildNode)
|
||||
|
||||
if (index >= 0) {
|
||||
// Calculate yoga index BEFORE modifying childNodes.
|
||||
// We can't use DOM index directly because some children (like ink-progress,
|
||||
// ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't
|
||||
// match yoga indices.
|
||||
let yogaIndex = 0
|
||||
|
||||
if (newChildNode.yogaNode && node.yogaNode) {
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (node.childNodes[i]?.yogaNode) {
|
||||
yogaIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.childNodes.splice(index, 0, newChildNode)
|
||||
|
||||
if (newChildNode.yogaNode && node.yogaNode) {
|
||||
node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
node.childNodes.push(newChildNode)
|
||||
|
||||
if (newChildNode.yogaNode) {
|
||||
node.yogaNode?.insertChild(newChildNode.yogaNode, node.yogaNode.getChildCount())
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const removeChildNode = (node: DOMElement, removeNode: DOMNode): void => {
|
||||
if (removeNode.yogaNode) {
|
||||
removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode)
|
||||
}
|
||||
|
||||
// Collect cached rects from the removed subtree so they can be cleared
|
||||
collectRemovedRects(node, removeNode)
|
||||
|
||||
removeNode.parentNode = undefined
|
||||
|
||||
const index = node.childNodes.indexOf(removeNode)
|
||||
|
||||
if (index >= 0) {
|
||||
node.childNodes.splice(index, 1)
|
||||
}
|
||||
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function collectRemovedRects(parent: DOMElement, removed: DOMNode, underAbsolute = false): void {
|
||||
if (removed.nodeName === '#text') {
|
||||
return
|
||||
}
|
||||
|
||||
const elem = removed as DOMElement
|
||||
// If this node or any ancestor in the removed subtree was absolute,
|
||||
// its painted pixels may overlap non-siblings — flag for global blit
|
||||
// disable. Normal-flow removals only affect direct siblings, which
|
||||
// hasRemovedChild already handles.
|
||||
const isAbsolute = underAbsolute || elem.style.position === 'absolute'
|
||||
const cached = nodeCache.get(elem)
|
||||
|
||||
if (cached) {
|
||||
addPendingClear(parent, cached, isAbsolute)
|
||||
nodeCache.delete(elem)
|
||||
}
|
||||
|
||||
for (const child of elem.childNodes) {
|
||||
collectRemovedRects(parent, child, isAbsolute)
|
||||
}
|
||||
}
|
||||
|
||||
export const setAttribute = (node: DOMElement, key: string, value: DOMNodeAttribute): void => {
|
||||
// Skip 'children' - React handles children via appendChild/removeChild,
|
||||
// not attributes. React always passes a new children reference, so
|
||||
// tracking it as an attribute would mark everything dirty every render.
|
||||
if (key === 'children') {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if unchanged
|
||||
if (node.attributes[key] === value) {
|
||||
return
|
||||
}
|
||||
|
||||
node.attributes[key] = value
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const setStyle = (node: DOMNode, style: Styles): void => {
|
||||
// Compare style properties to avoid marking dirty unnecessarily.
|
||||
// React creates new style objects on every render even when unchanged.
|
||||
if (stylesEqual(node.style, style)) {
|
||||
return
|
||||
}
|
||||
|
||||
node.style = style
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
export const setTextStyles = (node: DOMElement, textStyles: TextStyles): void => {
|
||||
// Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx)
|
||||
// allocate a new textStyles object on every render even when values are
|
||||
// unchanged, so compare by value to avoid markDirty -> yoga re-measurement
|
||||
// on every Text re-render.
|
||||
if (shallowEqual(node.textStyles, textStyles)) {
|
||||
return
|
||||
}
|
||||
|
||||
node.textStyles = textStyles
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function stylesEqual(a: Styles, b: Styles): boolean {
|
||||
return shallowEqual(a, b)
|
||||
}
|
||||
|
||||
function shallowEqual<T extends object>(a: T | undefined, b: T | undefined): boolean {
|
||||
// Fast path: same object reference (or both undefined)
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a === undefined || b === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get all keys from both objects
|
||||
const aKeys = Object.keys(a) as (keyof T)[]
|
||||
const bKeys = Object.keys(b) as (keyof T)[]
|
||||
|
||||
// Different number of properties
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare each property
|
||||
for (const key of aKeys) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const createTextNode = (text: string): TextNode => {
|
||||
const node: TextNode = {
|
||||
nodeName: '#text',
|
||||
nodeValue: text,
|
||||
yogaNode: undefined,
|
||||
parentNode: undefined,
|
||||
style: {}
|
||||
}
|
||||
|
||||
setTextNodeValue(node, text)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
const measureTextNode = function (
|
||||
node: DOMNode,
|
||||
width: number,
|
||||
widthMode: LayoutMeasureMode
|
||||
): { width: number; height: number } {
|
||||
const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
|
||||
|
||||
// Expand tabs for measurement (worst case: 8 spaces each).
|
||||
// Actual tab expansion happens in output.ts based on screen position.
|
||||
const text = expandTabs(rawText)
|
||||
|
||||
const dimensions = measureText(text, width)
|
||||
|
||||
// Text fits into container, no need to wrap
|
||||
if (dimensions.width <= width) {
|
||||
return dimensions
|
||||
}
|
||||
|
||||
// This is happening when <Box> is shrinking child nodes and layout asks
|
||||
// if we can fit this text node in a <1px space, so we just say "no"
|
||||
if (dimensions.width >= 1 && width > 0 && width < 1) {
|
||||
return dimensions
|
||||
}
|
||||
|
||||
// For text with embedded newlines (pre-wrapped content), avoid re-wrapping
|
||||
// at measurement width when layout is asking for intrinsic size (Undefined mode).
|
||||
// This prevents height inflation during min/max size checks.
|
||||
//
|
||||
// However, when layout provides an actual constraint (Exactly or AtMost mode),
|
||||
// we must respect it and measure at that width. Otherwise, if the actual
|
||||
// rendering width is smaller than the natural width, the text will wrap to
|
||||
// more lines than layout expects, causing content to be truncated.
|
||||
if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
|
||||
const effectiveWidth = Math.max(width, dimensions.width)
|
||||
|
||||
return measureText(text, effectiveWidth)
|
||||
}
|
||||
|
||||
const textWrap = node.style?.textWrap ?? 'wrap'
|
||||
const wrappedText = wrapText(text, width, textWrap)
|
||||
|
||||
return measureText(wrappedText, width)
|
||||
}
|
||||
|
||||
// ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.
|
||||
// No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)
|
||||
// already wrapped to the target width and each line is exactly one terminal row.
|
||||
const measureRawAnsiNode = function (node: DOMElement): {
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
return {
|
||||
width: node.attributes['rawWidth'] as number,
|
||||
height: node.attributes['rawHeight'] as number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a node and all its ancestors as dirty for re-rendering.
|
||||
* Also marks yoga dirty for text remeasurement if this is a text node.
|
||||
*/
|
||||
export const markDirty = (node?: DOMNode): void => {
|
||||
let current: DOMNode | undefined = node
|
||||
let markedYoga = false
|
||||
|
||||
while (current) {
|
||||
if (current.nodeName !== '#text') {
|
||||
;(current as DOMElement).dirty = true
|
||||
|
||||
// Only mark yoga dirty on leaf nodes that have measure functions
|
||||
if (!markedYoga && (current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') && current.yogaNode) {
|
||||
current.yogaNode.markDirty()
|
||||
markedYoga = true
|
||||
}
|
||||
}
|
||||
|
||||
current = current.parentNode
|
||||
}
|
||||
}
|
||||
|
||||
// Walk to root and call its onRender (the throttled scheduleRender). Use for
|
||||
// DOM-level mutations (scrollTop changes) that should trigger an Ink frame
|
||||
// without going through React's reconciler. Pair with markDirty() so the
|
||||
// renderer knows which subtree to re-evaluate.
|
||||
export const scheduleRenderFrom = (node?: DOMNode): void => {
|
||||
let cur: DOMNode | undefined = node
|
||||
|
||||
while (cur?.parentNode) {
|
||||
cur = cur.parentNode
|
||||
}
|
||||
|
||||
if (cur && cur.nodeName !== '#text') {
|
||||
;(cur as DOMElement).onRender?.()
|
||||
}
|
||||
}
|
||||
|
||||
export const setTextNodeValue = (node: TextNode, text: string): void => {
|
||||
if (typeof text !== 'string') {
|
||||
text = String(text)
|
||||
}
|
||||
|
||||
// Skip if unchanged
|
||||
if (node.nodeValue === text) {
|
||||
return
|
||||
}
|
||||
|
||||
node.nodeValue = text
|
||||
markDirty(node)
|
||||
}
|
||||
|
||||
function isDOMElement(node: DOMElement | TextNode): node is DOMElement {
|
||||
return node.nodeName !== '#text'
|
||||
}
|
||||
|
||||
// Clear yogaNode references recursively before freeing.
|
||||
// freeRecursive() frees the node and ALL its children, so we must clear
|
||||
// all yogaNode references to prevent dangling pointers.
|
||||
export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
|
||||
if ('childNodes' in node) {
|
||||
for (const child of node.childNodes) {
|
||||
clearYogaNodeReferences(child)
|
||||
}
|
||||
}
|
||||
|
||||
node.yogaNode = undefined
|
||||
}
|
||||
38
ui-tui/packages/hermes-ink/src/ink/events/click-event.ts
Normal file
38
ui-tui/packages/hermes-ink/src/ink/events/click-event.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Event } from './event.js'
|
||||
|
||||
/**
|
||||
* Mouse click event. Fired on left-button release without drag, only when
|
||||
* mouse tracking is enabled (i.e. inside <AlternateScreen>).
|
||||
*
|
||||
* Bubbles from the deepest hit node up through parentNode. Call
|
||||
* stopImmediatePropagation() to prevent ancestors' onClick from firing.
|
||||
*/
|
||||
export class ClickEvent extends Event {
|
||||
/** 0-indexed screen column of the click */
|
||||
readonly col: number
|
||||
/** 0-indexed screen row of the click */
|
||||
readonly row: number
|
||||
/**
|
||||
* Click column relative to the current handler's Box (col - box.x).
|
||||
* Recomputed by dispatchClick before each handler fires, so an onClick
|
||||
* on a container sees coords relative to that container, not to any
|
||||
* child the click landed on.
|
||||
*/
|
||||
localCol = 0
|
||||
/** Click row relative to the current handler's Box (row - box.y). */
|
||||
localRow = 0
|
||||
/**
|
||||
* True if the clicked cell has no visible content (unwritten in the
|
||||
* screen buffer — both packed words are 0). Handlers can check this to
|
||||
* ignore clicks on blank space to the right of text, so accidental
|
||||
* clicks on empty terminal space don't toggle state.
|
||||
*/
|
||||
readonly cellIsBlank: boolean
|
||||
|
||||
constructor(col: number, row: number, cellIsBlank: boolean) {
|
||||
super()
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.cellIsBlank = cellIsBlank
|
||||
}
|
||||
}
|
||||
242
ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts
Normal file
242
ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import {
|
||||
ContinuousEventPriority,
|
||||
DefaultEventPriority,
|
||||
DiscreteEventPriority,
|
||||
NoEventPriority
|
||||
} from 'react-reconciler/constants.js'
|
||||
|
||||
import { logError } from '../../utils/log.js'
|
||||
|
||||
import { HANDLER_FOR_EVENT } from './event-handlers.js'
|
||||
import type { EventTarget, TerminalEvent } from './terminal-event.js'
|
||||
|
||||
// --
|
||||
|
||||
type DispatchListener = {
|
||||
node: EventTarget
|
||||
handler: (event: TerminalEvent) => void
|
||||
phase: 'capturing' | 'at_target' | 'bubbling'
|
||||
}
|
||||
|
||||
function getHandler(
|
||||
node: EventTarget,
|
||||
eventType: string,
|
||||
capture: boolean
|
||||
): ((event: TerminalEvent) => void) | undefined {
|
||||
const handlers = node._eventHandlers
|
||||
|
||||
if (!handlers) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const mapping = HANDLER_FOR_EVENT[eventType]
|
||||
|
||||
if (!mapping) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const propName = capture ? mapping.capture : mapping.bubble
|
||||
|
||||
if (!propName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return handlers[propName] as ((event: TerminalEvent) => void) | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all listeners for an event in dispatch order.
|
||||
*
|
||||
* Uses react-dom's two-phase accumulation pattern:
|
||||
* - Walk from target to root
|
||||
* - Capture handlers are prepended (unshift) → root-first
|
||||
* - Bubble handlers are appended (push) → target-first
|
||||
*
|
||||
* Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
|
||||
*/
|
||||
function collectListeners(target: EventTarget, event: TerminalEvent): DispatchListener[] {
|
||||
const listeners: DispatchListener[] = []
|
||||
|
||||
let node: EventTarget | undefined = target
|
||||
|
||||
while (node) {
|
||||
const isTarget = node === target
|
||||
|
||||
const captureHandler = getHandler(node, event.type, true)
|
||||
const bubbleHandler = getHandler(node, event.type, false)
|
||||
|
||||
if (captureHandler) {
|
||||
listeners.unshift({
|
||||
node,
|
||||
handler: captureHandler,
|
||||
phase: isTarget ? 'at_target' : 'capturing'
|
||||
})
|
||||
}
|
||||
|
||||
if (bubbleHandler && (event.bubbles || isTarget)) {
|
||||
listeners.push({
|
||||
node,
|
||||
handler: bubbleHandler,
|
||||
phase: isTarget ? 'at_target' : 'bubbling'
|
||||
})
|
||||
}
|
||||
|
||||
node = node.parentNode
|
||||
}
|
||||
|
||||
return listeners
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute collected listeners with propagation control.
|
||||
*
|
||||
* Before each handler, calls event._prepareForTarget(node) so event
|
||||
* subclasses can do per-node setup.
|
||||
*/
|
||||
function processDispatchQueue(listeners: DispatchListener[], event: TerminalEvent): void {
|
||||
let previousNode: EventTarget | undefined
|
||||
|
||||
for (const { node, handler, phase } of listeners) {
|
||||
if (event._isImmediatePropagationStopped()) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event._isPropagationStopped() && node !== previousNode) {
|
||||
break
|
||||
}
|
||||
|
||||
event._setEventPhase(phase)
|
||||
event._setCurrentTarget(node)
|
||||
event._prepareForTarget(node)
|
||||
|
||||
try {
|
||||
handler(event)
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
|
||||
previousNode = node
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
/**
|
||||
* Map terminal event types to React scheduling priorities.
|
||||
* Mirrors react-dom's getEventPriority() switch.
|
||||
*/
|
||||
function getEventPriority(eventType: string): number {
|
||||
switch (eventType) {
|
||||
case 'keydown':
|
||||
|
||||
case 'keyup':
|
||||
|
||||
case 'click':
|
||||
|
||||
case 'focus':
|
||||
|
||||
case 'blur':
|
||||
|
||||
case 'paste':
|
||||
return DiscreteEventPriority as number
|
||||
|
||||
case 'resize':
|
||||
|
||||
case 'scroll':
|
||||
|
||||
case 'mousemove':
|
||||
return ContinuousEventPriority as number
|
||||
|
||||
default:
|
||||
return DefaultEventPriority as number
|
||||
}
|
||||
}
|
||||
|
||||
// --
|
||||
|
||||
type DiscreteUpdates = <A, B>(fn: (a: A, b: B) => boolean, a: A, b: B, c: undefined, d: undefined) => boolean
|
||||
|
||||
/**
|
||||
* Owns event dispatch state and the capture/bubble dispatch loop.
|
||||
*
|
||||
* The reconciler host config reads currentEvent and currentUpdatePriority
|
||||
* to implement resolveUpdatePriority, resolveEventType, and
|
||||
* resolveEventTimeStamp — mirroring how react-dom's host config reads
|
||||
* ReactDOMSharedInternals and window.event.
|
||||
*
|
||||
* discreteUpdates is injected after construction (by InkReconciler)
|
||||
* to break the import cycle.
|
||||
*/
|
||||
export class Dispatcher {
|
||||
currentEvent: TerminalEvent | null = null
|
||||
currentUpdatePriority: number = DefaultEventPriority as number
|
||||
discreteUpdates: DiscreteUpdates | null = null
|
||||
|
||||
/**
|
||||
* Infer event priority from the currently-dispatching event.
|
||||
* Called by the reconciler host config's resolveUpdatePriority
|
||||
* when no explicit priority has been set.
|
||||
*/
|
||||
resolveEventPriority(): number {
|
||||
if (this.currentUpdatePriority !== (NoEventPriority as number)) {
|
||||
return this.currentUpdatePriority
|
||||
}
|
||||
|
||||
if (this.currentEvent) {
|
||||
return getEventPriority(this.currentEvent.type)
|
||||
}
|
||||
|
||||
return DefaultEventPriority as number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event through capture and bubble phases.
|
||||
* Returns true if preventDefault() was NOT called.
|
||||
*/
|
||||
dispatch(target: EventTarget, event: TerminalEvent): boolean {
|
||||
const previousEvent = this.currentEvent
|
||||
this.currentEvent = event
|
||||
|
||||
try {
|
||||
event._setTarget(target)
|
||||
|
||||
const listeners = collectListeners(target, event)
|
||||
processDispatchQueue(listeners, event)
|
||||
|
||||
event._setEventPhase('none')
|
||||
event._setCurrentTarget(null)
|
||||
|
||||
return !event.defaultPrevented
|
||||
} finally {
|
||||
this.currentEvent = previousEvent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch with discrete (sync) priority.
|
||||
* For user-initiated events: keyboard, click, focus, paste.
|
||||
*/
|
||||
dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean {
|
||||
if (!this.discreteUpdates) {
|
||||
return this.dispatch(target, event)
|
||||
}
|
||||
|
||||
return this.discreteUpdates((t, e) => this.dispatch(t, e), target, event, undefined, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch with continuous priority.
|
||||
* For high-frequency events: resize, scroll, mouse move.
|
||||
*/
|
||||
dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean {
|
||||
const previousPriority = this.currentUpdatePriority
|
||||
|
||||
try {
|
||||
this.currentUpdatePriority = ContinuousEventPriority as number
|
||||
|
||||
return this.dispatch(target, event)
|
||||
} finally {
|
||||
this.currentUpdatePriority = previousPriority
|
||||
}
|
||||
}
|
||||
}
|
||||
40
ui-tui/packages/hermes-ink/src/ink/events/emitter.ts
Normal file
40
ui-tui/packages/hermes-ink/src/ink/events/emitter.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { EventEmitter as NodeEventEmitter } from 'events'
|
||||
|
||||
import { Event } from './event.js'
|
||||
|
||||
// Similar to node's builtin EventEmitter, but is also aware of our `Event`
|
||||
// class, and so `emit` respects `stopImmediatePropagation()`.
|
||||
export class EventEmitter extends NodeEventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
// Disable the default maxListeners warning. In React, many components
|
||||
// can legitimately listen to the same event (e.g., useInput hooks).
|
||||
// The default limit of 10 causes spurious warnings.
|
||||
this.setMaxListeners(0)
|
||||
}
|
||||
|
||||
override emit(type: string | symbol, ...args: unknown[]): boolean {
|
||||
// Delegate to node for `error`, since it's not treated like a normal event
|
||||
if (type === 'error') {
|
||||
return super.emit(type, ...args)
|
||||
}
|
||||
|
||||
const listeners = this.rawListeners(type)
|
||||
|
||||
if (listeners.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ccEvent = args[0] instanceof Event ? args[0] : null
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener.apply(this, args)
|
||||
|
||||
if (ccEvent?.didStopImmediatePropagation()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
84
ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts
Normal file
84
ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { ClickEvent } from './click-event.js'
|
||||
import type { FocusEvent } from './focus-event.js'
|
||||
import type { KeyboardEvent } from './keyboard-event.js'
|
||||
import type { MouseEvent } from './mouse-event.js'
|
||||
import type { PasteEvent } from './paste-event.js'
|
||||
import type { ResizeEvent } from './resize-event.js'
|
||||
|
||||
type KeyboardEventHandler = (event: KeyboardEvent) => void
|
||||
type FocusEventHandler = (event: FocusEvent) => void
|
||||
type PasteEventHandler = (event: PasteEvent) => void
|
||||
type ResizeEventHandler = (event: ResizeEvent) => void
|
||||
type ClickEventHandler = (event: ClickEvent) => void
|
||||
type MouseEventHandler = (event: MouseEvent) => void
|
||||
type HoverEventHandler = () => void
|
||||
|
||||
/**
|
||||
* Props for event handlers on Box and other host components.
|
||||
*
|
||||
* Follows the React/DOM naming convention:
|
||||
* - onEventName: handler for bubble phase
|
||||
* - onEventNameCapture: handler for capture phase
|
||||
*/
|
||||
export type EventHandlerProps = {
|
||||
onKeyDown?: KeyboardEventHandler
|
||||
onKeyDownCapture?: KeyboardEventHandler
|
||||
|
||||
onFocus?: FocusEventHandler
|
||||
onFocusCapture?: FocusEventHandler
|
||||
onBlur?: FocusEventHandler
|
||||
onBlurCapture?: FocusEventHandler
|
||||
|
||||
onPaste?: PasteEventHandler
|
||||
onPasteCapture?: PasteEventHandler
|
||||
|
||||
onResize?: ResizeEventHandler
|
||||
|
||||
onClick?: ClickEventHandler
|
||||
onMouseDown?: MouseEventHandler
|
||||
onMouseUp?: MouseEventHandler
|
||||
onMouseDrag?: MouseEventHandler
|
||||
onMouseEnter?: HoverEventHandler
|
||||
onMouseLeave?: HoverEventHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup: event type string → handler prop names.
|
||||
* Used by the dispatcher for O(1) handler lookup per node.
|
||||
*/
|
||||
export const HANDLER_FOR_EVENT: Record<
|
||||
string,
|
||||
{ bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps }
|
||||
> = {
|
||||
keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' },
|
||||
focus: { bubble: 'onFocus', capture: 'onFocusCapture' },
|
||||
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
|
||||
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||
resize: { bubble: 'onResize' },
|
||||
click: { bubble: 'onClick' },
|
||||
mousedown: { bubble: 'onMouseDown' },
|
||||
mouseup: { bubble: 'onMouseUp' },
|
||||
mousedrag: { bubble: 'onMouseDrag' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of all event handler prop names, for the reconciler to detect
|
||||
* event props and store them in _eventHandlers instead of attributes.
|
||||
*/
|
||||
export const EVENT_HANDLER_PROPS = new Set<string>([
|
||||
'onKeyDown',
|
||||
'onKeyDownCapture',
|
||||
'onFocus',
|
||||
'onFocusCapture',
|
||||
'onBlur',
|
||||
'onBlurCapture',
|
||||
'onPaste',
|
||||
'onPasteCapture',
|
||||
'onResize',
|
||||
'onClick',
|
||||
'onMouseDown',
|
||||
'onMouseUp',
|
||||
'onMouseDrag',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave'
|
||||
])
|
||||
11
ui-tui/packages/hermes-ink/src/ink/events/event.ts
Normal file
11
ui-tui/packages/hermes-ink/src/ink/events/event.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export class Event {
|
||||
private _didStopImmediatePropagation = false
|
||||
|
||||
didStopImmediatePropagation(): boolean {
|
||||
return this._didStopImmediatePropagation
|
||||
}
|
||||
|
||||
stopImmediatePropagation(): void {
|
||||
this._didStopImmediatePropagation = true
|
||||
}
|
||||
}
|
||||
18
ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts
Normal file
18
ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { type EventTarget, TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Focus event for component focus changes.
|
||||
*
|
||||
* Dispatched when focus moves between elements. 'focus' fires on the
|
||||
* newly focused element, 'blur' fires on the previously focused one.
|
||||
* Both bubble, matching react-dom's use of focusin/focusout semantics
|
||||
* so parent components can observe descendant focus changes.
|
||||
*/
|
||||
export class FocusEvent extends TerminalEvent {
|
||||
readonly relatedTarget: EventTarget | null
|
||||
|
||||
constructor(type: 'focus' | 'blur', relatedTarget: EventTarget | null = null) {
|
||||
super(type, { bubbles: true, cancelable: false })
|
||||
this.relatedTarget = relatedTarget
|
||||
}
|
||||
}
|
||||
184
ui-tui/packages/hermes-ink/src/ink/events/input-event.ts
Normal file
184
ui-tui/packages/hermes-ink/src/ink/events/input-event.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js'
|
||||
|
||||
import { Event } from './event.js'
|
||||
|
||||
export type Key = {
|
||||
upArrow: boolean
|
||||
downArrow: boolean
|
||||
leftArrow: boolean
|
||||
rightArrow: boolean
|
||||
pageDown: boolean
|
||||
pageUp: boolean
|
||||
wheelUp: boolean
|
||||
wheelDown: boolean
|
||||
home: boolean
|
||||
end: boolean
|
||||
return: boolean
|
||||
escape: boolean
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
fn: boolean
|
||||
tab: boolean
|
||||
backspace: boolean
|
||||
delete: boolean
|
||||
meta: boolean
|
||||
super: boolean
|
||||
}
|
||||
|
||||
function parseKey(keypress: ParsedKey): [Key, string] {
|
||||
const key: Key = {
|
||||
upArrow: keypress.name === 'up',
|
||||
downArrow: keypress.name === 'down',
|
||||
leftArrow: keypress.name === 'left',
|
||||
rightArrow: keypress.name === 'right',
|
||||
pageDown: keypress.name === 'pagedown',
|
||||
pageUp: keypress.name === 'pageup',
|
||||
wheelUp: keypress.name === 'wheelup',
|
||||
wheelDown: keypress.name === 'wheeldown',
|
||||
home: keypress.name === 'home',
|
||||
end: keypress.name === 'end',
|
||||
return: keypress.name === 'return',
|
||||
escape: keypress.name === 'escape',
|
||||
fn: keypress.fn,
|
||||
ctrl: keypress.ctrl,
|
||||
shift: keypress.shift,
|
||||
tab: keypress.name === 'tab',
|
||||
backspace: keypress.name === 'backspace',
|
||||
delete: keypress.name === 'delete',
|
||||
// `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false
|
||||
// but with option = true, so we need to take this into account here
|
||||
// to avoid breaking changes in Ink.
|
||||
// TODO(vadimdemedes): consider removing this in the next major version.
|
||||
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
|
||||
// Super (Cmd on macOS / Win key) — only arrives via kitty keyboard
|
||||
// protocol CSI u sequences. Distinct from meta (Alt/Option) so
|
||||
// bindings like cmd+c can be expressed separately from opt+c.
|
||||
super: keypress.super
|
||||
}
|
||||
|
||||
let input = keypress.ctrl ? keypress.name : keypress.sequence
|
||||
|
||||
// Handle undefined input case
|
||||
if (input === undefined) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// When ctrl is set, keypress.name for space is the literal word "space".
|
||||
// Convert to actual space character for consistency with the CSI u branch
|
||||
// (which maps 'space' → ' '). Without this, ctrl+space leaks the literal
|
||||
// word "space" into text input.
|
||||
if (keypress.ctrl && input === 'space') {
|
||||
input = ' '
|
||||
}
|
||||
|
||||
// Suppress unrecognized escape sequences that were parsed as function keys
|
||||
// (matched by FN_KEY_RE) but have no name in the keyName map.
|
||||
// Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc.
|
||||
// Without this, the ESC prefix is stripped below and the remainder (e.g.,
|
||||
// "[25~") leaks into the input as literal text.
|
||||
if (keypress.code && !keypress.name) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks
|
||||
// the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across
|
||||
// stdin chunks gets its buffered ESC flushed as a lone Escape key, and the
|
||||
// continuation arrives as a text token with name='' — which falls through
|
||||
// all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys
|
||||
// clear below (name is falsy). The fragment then leaks into the prompt as
|
||||
// literal `[<64;74;16M`. This is the same defensive sink as the F13 guard
|
||||
// above; the underlying tokenizer-flush race is upstream of this layer.
|
||||
if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Strip meta if it's still remaining after `parseKeypress`
|
||||
// TODO(vadimdemedes): remove this in the next major version.
|
||||
if (input.startsWith('\u001B')) {
|
||||
input = input.slice(1)
|
||||
}
|
||||
|
||||
// Track whether we've already processed this as a special sequence
|
||||
// that converted input to the key name (CSI u or application keypad mode).
|
||||
// For these, we don't want to clear input with nonAlphanumericKeys check.
|
||||
let processedAsSpecialSequence = false
|
||||
|
||||
// Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC,
|
||||
// we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b).
|
||||
// Use the parsed key name instead for input handling. Require a digit
|
||||
// after [ — real CSI u is always [<digits>…u, and a bare startsWith('[')
|
||||
// false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the
|
||||
// literal text "mouse" into the prompt via processedAsSpecialSequence.
|
||||
if (/^\[\d/.test(input) && input.endsWith('u')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav,
|
||||
// bare modifiers, etc.) — keycodeToName() returned undefined. Swallow
|
||||
// so the raw "[57358u" doesn't leak into the prompt. See #38781.
|
||||
input = ''
|
||||
} else {
|
||||
// 'space' → ' '; 'escape' → '' (key.escape carries it;
|
||||
// processedAsSpecialSequence bypasses the nonAlphanumericKeys
|
||||
// clear below, so we must handle it explicitly here);
|
||||
// otherwise use key name.
|
||||
input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name
|
||||
}
|
||||
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left
|
||||
// with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same
|
||||
// extraction as CSI u — without this, printable-char keycodes (single-letter
|
||||
// names) skip the nonAlphanumericKeys clear and leak "[27;..." as input.
|
||||
if (input.startsWith('[27;') && input.endsWith('~')) {
|
||||
if (!keypress.name) {
|
||||
// Unmapped modifyOtherKeys keycode — swallow for consistency with
|
||||
// the CSI u handler above. Practically untriggerable today (xterm
|
||||
// modifyOtherKeys only sends ASCII keycodes, all mapped), but
|
||||
// guards against future terminal behavior.
|
||||
input = ''
|
||||
} else {
|
||||
input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name
|
||||
}
|
||||
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Handle application keypad mode sequences: after stripping ESC,
|
||||
// we're left with "O<letter>" (e.g., "Op" for numpad 0, "Oy" for numpad 9).
|
||||
// Use the parsed key name (the digit character) for input handling.
|
||||
if (input.startsWith('O') && input.length === 2 && keypress.name && keypress.name.length === 1) {
|
||||
input = keypress.name
|
||||
processedAsSpecialSequence = true
|
||||
}
|
||||
|
||||
// Clear input for non-alphanumeric keys (arrows, function keys, etc.)
|
||||
// Skip this for CSI u and application keypad mode sequences since
|
||||
// those were already converted to their proper input characters.
|
||||
if (!processedAsSpecialSequence && keypress.name && nonAlphanumericKeys.includes(keypress.name)) {
|
||||
input = ''
|
||||
}
|
||||
|
||||
// Set shift=true for uppercase letters (A-Z)
|
||||
// Must check it's actually a letter, not just any char unchanged by toUpperCase
|
||||
if (input.length === 1 && typeof input[0] === 'string' && input[0] >= 'A' && input[0] <= 'Z') {
|
||||
key.shift = true
|
||||
}
|
||||
|
||||
return [key, input]
|
||||
}
|
||||
|
||||
export class InputEvent extends Event {
|
||||
readonly keypress: ParsedKey
|
||||
readonly key: Key
|
||||
readonly input: string
|
||||
|
||||
constructor(keypress: ParsedKey) {
|
||||
super()
|
||||
const [key, input] = parseKey(keypress)
|
||||
|
||||
this.keypress = keypress
|
||||
this.key = key
|
||||
this.input = input
|
||||
}
|
||||
}
|
||||
57
ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts
Normal file
57
ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { ParsedKey } from '../parse-keypress.js'
|
||||
|
||||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Keyboard event dispatched through the DOM tree via capture/bubble.
|
||||
*
|
||||
* Follows browser KeyboardEvent semantics: `key` is the literal character
|
||||
* for printable keys ('a', '3', ' ', '/') and a multi-char name for
|
||||
* special keys ('down', 'return', 'escape', 'f1'). The idiomatic
|
||||
* printable-char check is `e.key.length === 1`.
|
||||
*/
|
||||
export class KeyboardEvent extends TerminalEvent {
|
||||
readonly key: string
|
||||
readonly ctrl: boolean
|
||||
readonly shift: boolean
|
||||
readonly meta: boolean
|
||||
readonly superKey: boolean
|
||||
readonly fn: boolean
|
||||
|
||||
constructor(parsedKey: ParsedKey) {
|
||||
super('keydown', { bubbles: true, cancelable: true })
|
||||
|
||||
this.key = keyFromParsed(parsedKey)
|
||||
this.ctrl = parsedKey.ctrl
|
||||
this.shift = parsedKey.shift
|
||||
this.meta = parsedKey.meta || parsedKey.option
|
||||
this.superKey = parsedKey.super
|
||||
this.fn = parsedKey.fn
|
||||
}
|
||||
}
|
||||
|
||||
function keyFromParsed(parsed: ParsedKey): string {
|
||||
const seq = parsed.sequence ?? ''
|
||||
const name = parsed.name ?? ''
|
||||
|
||||
// Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the
|
||||
// letter. Browsers report e.key === 'c' with e.ctrlKey === true.
|
||||
if (parsed.ctrl) {
|
||||
return name
|
||||
}
|
||||
|
||||
// Single printable char (space through ~, plus anything above ASCII):
|
||||
// use the literal char. Browsers report e.key === '3', not 'Digit3'.
|
||||
if (seq.length === 1) {
|
||||
const code = seq.charCodeAt(0)
|
||||
|
||||
if (code >= 0x20 && code !== 0x7f) {
|
||||
return seq
|
||||
}
|
||||
}
|
||||
|
||||
// Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is
|
||||
// either an escape sequence (\x1b[B) or a control byte (\r, \t), so use
|
||||
// the parsed name. Browsers report e.key === 'ArrowDown'.
|
||||
return name || seq
|
||||
}
|
||||
18
ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts
Normal file
18
ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Event } from './event.js'
|
||||
|
||||
export class MouseEvent extends Event {
|
||||
readonly col: number
|
||||
readonly row: number
|
||||
localCol = 0
|
||||
localRow = 0
|
||||
readonly cellIsBlank: boolean
|
||||
readonly button: number
|
||||
|
||||
constructor(col: number, row: number, cellIsBlank: boolean, button: number) {
|
||||
super()
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.cellIsBlank = cellIsBlank
|
||||
this.button = button
|
||||
}
|
||||
}
|
||||
10
ui-tui/packages/hermes-ink/src/ink/events/paste-event.ts
Normal file
10
ui-tui/packages/hermes-ink/src/ink/events/paste-event.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
export class PasteEvent extends TerminalEvent {
|
||||
readonly text: string
|
||||
|
||||
constructor(text: string) {
|
||||
super('paste', { bubbles: true, cancelable: true })
|
||||
this.text = text
|
||||
}
|
||||
}
|
||||
12
ui-tui/packages/hermes-ink/src/ink/events/resize-event.ts
Normal file
12
ui-tui/packages/hermes-ink/src/ink/events/resize-event.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { TerminalEvent } from './terminal-event.js'
|
||||
|
||||
export class ResizeEvent extends TerminalEvent {
|
||||
readonly columns: number
|
||||
readonly rows: number
|
||||
|
||||
constructor(columns: number, rows: number) {
|
||||
super('resize', { bubbles: true, cancelable: true })
|
||||
this.columns = columns
|
||||
this.rows = rows
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue