diff --git a/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md b/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md
new file mode 100644
index 000000000..5692c5e7a
--- /dev/null
+++ b/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md
@@ -0,0 +1,1170 @@
+# TUI Refactor: Current to Ideal
+
+Date: 2026-04-01
+
+## Scope
+
+- same repo refactor
+- keep Python runtime
+- replace PT-based interactive shell
+- add Ink UI through a local gateway
+
+## Current Environment
+
+Interactive path is centered in `cli.py` with `prompt_toolkit` and `rich`.
+
+Current technical shape:
+
+- PT app shell and key handling in `cli.py`
+ - `Application`, `KeyBindings`, `TextArea`, `patch_stdout`
+- queue control in `cli.py`
+ - `_pending_input`
+ - `_interrupt_queue`
+- approval and sudo callback globals in `tools/terminal_tool.py`
+ - `_approval_callback`
+ - `_sudo_password_callback`
+- runtime entry in `run_agent.py`
+ - `AIAgent.run_conversation()`
+ - `AIAgent.chat()`
+
+Current constraint:
+
+- UI logic and runtime control are mixed, so UI replacement is expensive.
+
+## Ideal Environment
+
+Interactive path is split into three layers:
+
+1. Ink UI (Node/TS)
+2. local `tui_gateway` over stdio JSON-RPC
+3. Python runtime (`AIAgent`, tools, sessions)
+
+Rules for ideal state:
+
+- no direct UI to `AIAgent` calls
+- no PT dependency in gateway path
+- keep current Hermes state/config contracts
+ - `~/.hermes/.env`
+ - `~/.hermes/config.yaml`
+ - `~/.hermes/state.db`
+ - profile behavior via `HERMES_HOME`
+
+## Migration Path
+
+## Cut 1: Headless Controller
+
+Add:
+
+- `tui_gateway/controller.py`
+- `tui_gateway/session_state.py`
+- `tui_gateway/events.py`
+
+Change:
+
+- `run_agent.py` callback wiring for controller events
+- `cli.py` compatibility bridge into controller
+
+Done:
+
+- create/resume/prompt/interrupt/cancel work with no PT imports
+
+## Cut 2: Local Gateway
+
+Add:
+
+- `tui_gateway/protocol.py`
+- `tui_gateway/server.py`
+- `tui_gateway/entry.py`
+
+Methods:
+
+- `session.create`
+- `session.resume`
+- `session.list`
+- `session.interrupt`
+- `session.cancel`
+- `prompt.submit`
+- `approval.respond`
+- `sudo.respond`
+- `clarify.respond`
+
+Events:
+
+- `message.delta`
+- `tool.progress`
+- `approval.requested`
+- `sudo.requested`
+- `clarify.requested`
+- `error`
+
+Done:
+
+- simple client completes full prompt cycle through JSON-RPC
+
+## Cut 3: Ink UI
+
+Add:
+
+- `ui-tui/src/main.tsx`
+- `ui-tui/src/gatewayClient.ts`
+- `ui-tui/src/state/store.ts`
+- `ui-tui/src/components/Transcript.tsx`
+- `ui-tui/src/components/Composer.tsx`
+- `ui-tui/src/components/StatusBar.tsx`
+- `ui-tui/src/components/ApprovalModal.tsx`
+- `ui-tui/src/components/SudoPrompt.tsx`
+- `ui-tui/src/components/ClarifyPrompt.tsx`
+
+Change:
+
+- `tools/terminal_tool.py` prompt adapters for gateway round-trip
+
+Done:
+
+- chat, tools, approval, sudo, clarify, interrupt all work through gateway
+
+## Cut 4: Opt-In and Rollout
+
+Entry points:
+
+- `hermes --tui`
+- `HERMES_EXPERIMENTAL_TUI=1`
+- `display.experimental_tui: true`
+- `/tui`, `/tui on`, `/tui off`, `/tui status`
+
+Behavior:
+
+- `/tui` starts gateway if needed and attaches
+- failed attach falls back to PT mode with explicit error text
+- `/tui off` disables auto-launch only
+
+Rollout:
+
+1. internal opt-in
+2. external opt-in beta
+3. default-on after checks pass
+4. remove PT path later
+
+## Acceptance Checks
+
+- runtime: no PT import in controller/gateway path
+- state: same config/profile/session continuity
+- commands: slash command registry remains `hermes_cli/commands.py`
+- permissions: approval/sudo/clarify protocol round-trip
+- streaming: incremental assistant and tool updates
+- opt-in: flag/env/config/slash command share one launch path
+
+## Test Commands
+
+- `python -m pytest tests/tui_gateway/test_controller.py -q`
+- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q`
+- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q`
+- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q`
+- `cd ui-tui && npm run build`
+- `cd ui-tui && npm run test`
+# Prompt Toolkit to Ink Migration Plan
+
+Date: 2026-04-01
+
+## Scope
+
+This is a refactor in the same repo.
+
+- no new repo
+- no runtime rewrite
+- no messaging gateway reuse for terminal UI
+
+## Current Environment
+
+Interactive Hermes today is `prompt_toolkit` plus `rich` inside `cli.py`.
+
+Current structure:
+
+- PT app shell and input handling in `cli.py`
+ - `Application`
+ - `KeyBindings`
+ - `TextArea`
+ - `patch_stdout`
+- queue-based control flow in `cli.py`
+ - `_pending_input`
+ - `_interrupt_queue`
+- approval and sudo callbacks in `tools/terminal_tool.py`
+ - `_approval_callback`
+ - `_sudo_password_callback`
+- core runtime in `run_agent.py`
+ - `AIAgent.run_conversation()`
+ - `AIAgent.chat()`
+
+Current issue:
+
+- UI framework logic and runtime control flow are mixed in one path.
+- Tool prompt routing depends on PT callback globals.
+- Replacing UI without changing runtime is harder than it should be.
+
+## Ideal Environment
+
+Interactive Hermes is Ink UI plus a local TUI gateway.
+
+Target model:
+
+- Python runtime remains the source of truth.
+- UI talks to `tui_gateway` over stdio JSON-RPC.
+- `tui_gateway` talks to `AIAgent`.
+- no direct UI to `AIAgent` coupling.
+
+Target compatibility:
+
+- same `~/.hermes/.env`
+- same `~/.hermes/config.yaml`
+- same `~/.hermes/state.db`
+- same profile behavior through `HERMES_HOME`
+
+## How To Get There
+
+Use three delivery cuts and one switch cut.
+
+## Cut 1: Headless Runtime Controller
+
+Goal:
+
+- separate runtime control from PT.
+
+Add:
+
+- `tui_gateway/controller.py`
+- `tui_gateway/session_state.py`
+- `tui_gateway/events.py`
+
+Change:
+
+- `run_agent.py` callback wiring needed by controller
+- `cli.py` compatibility calls into controller
+
+Done when:
+
+- controller supports create/resume/prompt/interrupt/cancel
+- controller path imports no PT modules
+- tool progress and assistant deltas are typed events
+
+## Cut 2: Local TUI Gateway
+
+Goal:
+
+- add stable protocol boundary for UI.
+
+Add:
+
+- `tui_gateway/protocol.py`
+- `tui_gateway/server.py`
+- `tui_gateway/entry.py`
+- `tui_gateway/__init__.py`
+
+Protocol methods:
+
+- `session.create`
+- `session.resume`
+- `session.list`
+- `session.interrupt`
+- `session.cancel`
+- `prompt.submit`
+- `approval.respond`
+- `sudo.respond`
+- `clarify.respond`
+
+Protocol events:
+
+- `message.delta`
+- `tool.progress`
+- `approval.requested`
+- `sudo.requested`
+- `clarify.requested`
+- `error`
+
+Done when:
+
+- a simple client can complete one full prompt cycle over stdio JSON-RPC
+
+## Cut 3: Ink UI
+
+Goal:
+
+- usable clone flow through gateway.
+
+Add:
+
+- `ui-tui/package.json`
+- `ui-tui/src/main.tsx`
+- `ui-tui/src/gatewayClient.ts`
+- `ui-tui/src/state/store.ts`
+- `ui-tui/src/components/Transcript.tsx`
+- `ui-tui/src/components/Composer.tsx`
+- `ui-tui/src/components/StatusBar.tsx`
+- `ui-tui/src/components/ApprovalModal.tsx`
+- `ui-tui/src/components/SudoPrompt.tsx`
+- `ui-tui/src/components/ClarifyPrompt.tsx`
+
+Change:
+
+- `tools/terminal_tool.py` adapters for gateway request/response prompt routing
+
+Done when:
+
+- user can chat, run tools, approve, deny, clarify, interrupt, and continue
+
+## Cut 4: Opt-In Switch and Rollout
+
+Goal:
+
+- ship without forced cutover.
+
+Entry points:
+
+- `hermes --tui`
+- `HERMES_EXPERIMENTAL_TUI=1`
+- `display.experimental_tui: true`
+- `/tui`, `/tui on`, `/tui off`, `/tui status`
+
+Behavior:
+
+- `/tui` starts gateway if needed, then attaches
+- attach failure returns to PT mode with clear error text
+- `/tui off` disables auto-launch only
+
+Rollout sequence:
+
+1. internal opt-in
+2. external opt-in beta
+3. default-on after checks pass
+4. PT path removal later
+
+## Acceptance Checks
+
+- runtime
+ - no PT import in controller or gateway path
+ - deterministic interrupt/cancel
+- state
+ - same config, profile, and session continuity
+- commands
+ - slash command registry remains centralized in `hermes_cli/commands.py`
+- permissions
+ - approval, sudo, clarify round-trip through protocol
+- streaming
+ - incremental assistant and tool updates
+- opt-in
+ - flag, env, config, and slash command trigger the same launch path
+
+## Test Commands
+
+- `python -m pytest tests/tui_gateway/test_controller.py -q`
+- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q`
+- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q`
+- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q`
+- `cd ui-tui && npm run build`
+- `cd ui-tui && npm run test`
+
+## Non-Goals
+
+- no ACP extraction work as prerequisite
+- no new repository split
+- no direct UI to `AIAgent` coupling
+- no PT feature parity before gateway path is stable
+# Prompt Toolkit to Ink Migration Plan
+
+Date: 2026-04-01
+
+## Scope
+
+This is a refactor in the same repo.
+
+- no new repo
+- no runtime rewrite
+- no messaging gateway reuse for terminal UI
+
+## Current Environment (As-Is)
+
+Interactive Hermes today is `prompt_toolkit` plus `rich` inside `cli.py`.
+
+Facts from code:
+
+- PT imports and app shell in `cli.py`
+ - `Application`, `KeyBindings`, `TextArea`, `patch_stdout`
+- PT queue control path in `cli.py`
+ - `_pending_input` for normal input
+ - `_interrupt_queue` for input while agent is running
+- tool approval and sudo prompts use CLI callbacks in `tools/terminal_tool.py`
+ - `_sudo_password_callback`
+ - `_approval_callback`
+- core agent runtime is Python in `run_agent.py`
+ - `AIAgent.run_conversation()`
+ - `AIAgent.chat()`
+
+Current coupling problem:
+
+- UI framework and runtime control flow are mixed in `cli.py`.
+- Tool prompts depend on CLI callback globals.
+- This blocks clean UI replacement.
+
+## Ideal Environment (To-Be)
+
+Interactive Hermes is Ink UI plus a local TUI gateway.
+
+### Runtime
+
+- `AIAgent` stays in Python.
+- Tool execution stays in Python.
+- Session storage and config remain unchanged.
+
+### Boundary
+
+- new `tui_gateway` process over stdio JSON-RPC
+- UI talks only to gateway
+- gateway talks to `AIAgent`
+
+### UI
+
+- Node/TypeScript Ink app
+- transcript, composer, status, approvals, clarify, sudo, interrupt
+
+### Compatibility
+
+Use existing Hermes state and config:
+
+- `~/.hermes/.env`
+- `~/.hermes/config.yaml`
+- `~/.hermes/state.db`
+- profile behavior via `HERMES_HOME`
+
+## How To Get There
+
+Use three implementation cuts plus one switch cut.
+
+## Cut 1: Headless Runtime Controller
+
+Goal: separate runtime control flow from PT.
+
+Add:
+
+- `tui_gateway/controller.py`
+- `tui_gateway/session_state.py`
+- `tui_gateway/events.py`
+
+Change:
+
+- `run_agent.py` only for callback wiring needed by controller
+- `cli.py` to call controller APIs in compatibility mode
+
+Done when:
+
+- controller can create/resume/prompt/interrupt/cancel without importing PT
+- tool progress and assistant deltas are emitted as typed events
+
+## Cut 2: Local TUI Gateway
+
+Goal: protocol boundary between UI and runtime.
+
+Add:
+
+- `tui_gateway/protocol.py`
+- `tui_gateway/server.py`
+- `tui_gateway/entry.py`
+- `tui_gateway/__init__.py`
+
+Protocol methods:
+
+- `session.create`
+- `session.resume`
+- `session.list`
+- `session.interrupt`
+- `session.cancel`
+- `prompt.submit`
+- `approval.respond`
+- `sudo.respond`
+- `clarify.respond`
+
+Protocol events:
+
+- `message.delta`
+- `tool.progress`
+- `approval.requested`
+- `sudo.requested`
+- `clarify.requested`
+- `error`
+
+Done when:
+
+- a simple client can run one full prompt cycle over stdio JSON-RPC
+
+## Cut 3: Ink UI
+
+Goal: usable clone experience through gateway.
+
+Add:
+
+- `ui-tui/package.json`
+- `ui-tui/src/main.tsx`
+- `ui-tui/src/gatewayClient.ts`
+- `ui-tui/src/state/store.ts`
+- `ui-tui/src/components/Transcript.tsx`
+- `ui-tui/src/components/Composer.tsx`
+- `ui-tui/src/components/StatusBar.tsx`
+- `ui-tui/src/components/ApprovalModal.tsx`
+- `ui-tui/src/components/SudoPrompt.tsx`
+- `ui-tui/src/components/ClarifyPrompt.tsx`
+
+Change:
+
+- `tools/terminal_tool.py` adapters so prompts round-trip through gateway path, not PT-only callbacks
+
+Done when:
+
+- user can chat, run tools, approve, deny, clarify, interrupt, and continue
+
+## Cut 4: Opt-In Switch and Rollout
+
+Goal: ship safely without forced cutover.
+
+Entry points:
+
+- `hermes --tui`
+- `HERMES_EXPERIMENTAL_TUI=1`
+- `display.experimental_tui: true`
+- `/tui`, `/tui on`, `/tui off`, `/tui status` in legacy CLI
+
+Behavior:
+
+- `/tui` starts gateway if needed, then attaches
+- attach failure returns to PT mode with clear error text
+- `/tui off` disables auto-launch only
+
+Rollout:
+
+1. internal opt-in
+2. external opt-in beta
+3. default-on only after acceptance checks pass
+4. PT path removal later
+
+## Acceptance Checks
+
+- runtime
+ - no PT import in controller or gateway path
+ - deterministic interrupt/cancel
+- state
+ - same config, profile, and session continuity
+- commands
+ - slash command registry stays centralized in `hermes_cli/commands.py`
+- permissions
+ - approval, sudo, clarify all round-trip through protocol
+- streaming
+ - incremental assistant and tool updates
+- opt-in
+ - flag, env, config, and slash command all trigger same launch path
+
+## Test Commands
+
+- `python -m pytest tests/tui_gateway/test_controller.py -q`
+- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q`
+- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q`
+- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q`
+- `cd ui-tui && npm run build`
+- `cd ui-tui && npm run test`
+
+## Non-Goals
+
+- no ACP extraction work as prerequisite
+- no new repository split
+- no direct UI to `AIAgent` coupling
+- no PT feature parity before gateway path is stable
+# Ink Gateway TUI Migration Plan
+
+Date: 2026-04-01
+
+## Goal
+
+Replace Hermes' interactive `prompt_toolkit` CLI with a React terminal UI built on `Ink`, while keeping the Python agent and tool runtime in place.
+
+The new design should:
+
+- remove `prompt_toolkit` from the interactive path entirely
+- keep `AIAgent`, tool execution, and session logic in Python
+- introduce a transport-neutral local UI gateway between backend and frontend
+- use stock `Ink` first, not a Claude Code renderer transplant
+- keep using the same Hermes config, profile, skills, memory, and session storage model
+
+## Decision Summary
+
+Hermes should not evolve the current `prompt_toolkit` shell. The replacement architecture is:
+
+1. Python backend session server
+2. local gateway transport over stdio JSON-RPC
+3. Node/TypeScript `Ink` TUI frontend
+
+This intentionally uses a dedicated local TUI gateway and keeps `acp_adapter` unchanged.
+
+The new TUI is a new shell, not a new runtime.
+
+## Compatibility Requirements
+
+From the existing Hermes docs and setup flows, the new TUI should continue to use:
+
+- the same `~/.hermes/.env` provider/auth configuration
+- the same `~/.hermes/config.yaml` settings model
+- the same `~/.hermes/state.db` session store
+- the same `HERMES_HOME` profile layout and isolation rules
+- the same skills, memories, and slash-command registry already shared across Hermes surfaces
+
+The migration should not create:
+
+- a separate TUI-only config file
+- a separate TUI-only session database
+- a separate prompt assembly path with drift from existing Hermes runtime behavior
+
+## Why This Shape
+
+The current interactive CLI is too coupled to `prompt_toolkit` to incrementally clean up in place:
+
+- `cli.py` mixes input handling, rendering, approvals, clarify flows, voice, queues, and agent orchestration
+- `tools/terminal_tool.py` assumes UI callbacks installed by the CLI
+- the current event model is built around PT queues and threads, not a transport-neutral session API
+
+At the same time, a full port to Claude Code's custom renderer is the wrong first move:
+
+- Claude Code's TUI stack is not just `Ink`; it includes a product-coupled renderer fork and app bootstrap assumptions
+- Hermes does not need that complexity to reach a good first-party TUI
+- stock `Ink` is enough to validate the UI model and close the biggest UX gap first
+
+Operationally, a Node/TypeScript frontend is acceptable here because Hermes already ships with a Node-aware install story and already supports Node-based surfaces in the wider product.
+
+## Non-Goals
+
+- reusing the messaging gateway as the TUI transport
+- preserving `prompt_toolkit` compatibility
+- matching Claude Code internals one-for-one
+- rewriting Hermes' core agent or tool runtime in Node
+
+## High-Level Architecture
+
+The new interactive stack has three layers:
+
+1. `python runtime`
+ Owns `AIAgent`, tool execution, session state, approvals, interrupts, and filesystem/terminal tools.
+2. `ui gateway`
+ A local protocol server that exposes Hermes sessions as typed requests, responses, and events.
+3. `ink tui`
+ A React terminal app that renders transcript, composer, status, approvals, tool cards, and slash-command UX.
+
+Suggested process model:
+
+```text
+hermes
+ 1. launch ink tui (node)
+ 2. spawn python ui gateway over stdio
+ 3. create/resume session
+ 4. exchange JSON-RPC requests + streaming events
+```
+
+## Why Not The Existing Messaging Gateway
+
+The messaging gateway solves a different problem:
+
+- multi-platform message routing
+- user authorization and pairing
+- per-platform delivery behavior
+- long-running bot process management
+
+That stack is useful as architecture background, but it is the wrong seam for a local terminal app.
+
+The ACP adapter demonstrates the right boundary shape:
+
+- backend runtime behind a protocol boundary
+- callback/event bridging
+- permission round-trips
+- explicit session lifecycle
+
+The new local UI gateway should target a Hermes TUI protocol directly, not an editor protocol.
+
+## ACP Isolation Strategy
+
+Do not use ACP extraction as a prerequisite.
+
+Instead:
+
+1. build `tui_gateway` directly around `AIAgent`
+2. keep `acp_adapter/*` untouched during early migration
+3. allow shared-runtime refactors later only if they reduce real maintenance cost
+
+Reasons:
+
+- ACP payloads are editor-shaped and add translation overhead
+- ACP-first migration adds scope and time before the new TUI ships
+- owner direction favors a fast clone path with gateway indirection, not transport unification work
+
+## Proposed Backend Split
+
+Extract the following concerns out of `cli.py`:
+
+1. `session controller`
+ A headless controller for create, resume, prompt, interrupt, cancel, and slash-command dispatch.
+2. `event bridge`
+ Converts agent callbacks and tool progress into structured UI events.
+3. `permission bridge`
+ Converts dangerous-command approval, sudo prompts, and clarify requests into request/response interactions.
+4. `presentation adapters`
+ Optional formatting helpers for transcript items and tool previews, without owning terminal rendering.
+5. `gateway adapter`
+ A thin request/event layer for `tui_gateway` over stdio JSON-RPC.
+
+The backend must stop depending on a terminal UI framework for control flow.
+
+## Shared Runtime Invariants
+
+The backend remains the source of truth for:
+
+- prompt assembly
+- Honcho/memory synchronization
+- tool dispatch
+- approval policy
+- slash-command execution
+- session transitions
+
+The frontend should render protocol state, not own core agent behavior.
+
+In particular, the new TUI must not introduce UI-side blocking work into the turn path. Context, memory, Honcho prefetch, and similar backend concerns should stay behind the runtime boundary and preserve Hermes' existing caching and async-prefetch behavior.
+
+## Proposed Transport
+
+Start with stdio JSON-RPC.
+
+Reasons:
+
+- local CLI startup is simple
+- process ownership is clear
+- no port management
+
+WebSocket can be added later if Hermes wants:
+
+- remote terminal clients
+- browser UI
+- multiple concurrent viewers
+
+But it should not be the first transport.
+
+## Platform And Toolset Strategy
+
+The new UI should run Hermes in a dedicated `tui` platform mode.
+
+That mode should:
+
+- share most behavioral semantics with the current interactive CLI
+- reuse the canonical slash-command registry rather than fork it
+- preserve session continuity with other Hermes surfaces where the shared state model already supports it
+- avoid editor-specific payload conventions in the TUI protocol
+
+Toolset strategy:
+
+- start from current interactive CLI capabilities
+- only introduce a dedicated `hermes-tui` toolset if the transport boundary proves it is needed
+- keep transport constraints out of tool business logic as much as possible
+
+## Protocol Shape
+
+The TUI protocol should be explicit and event-driven.
+
+Core requests:
+
+- `session.create`
+- `session.resume`
+- `session.list`
+- `session.interrupt`
+- `session.cancel`
+- `session.set_cwd`
+- `prompt.submit`
+- `command.run`
+- `approval.respond`
+- `sudo.respond`
+- `clarify.respond`
+
+Core events:
+
+- `session.state`
+- `message.start`
+- `message.delta`
+- `message.complete`
+- `thinking.delta`
+- `tool.started`
+- `tool.progress`
+- `tool.completed`
+- `approval.requested`
+- `sudo.requested`
+- `clarify.requested`
+- `error`
+
+Design rule: every user-visible interactive state in the new TUI must come from protocol state, not local UI guesswork.
+
+## Ink Frontend Scope
+
+The first `Ink` frontend only needs a narrow set of surfaces:
+
+- transcript view
+- input composer
+- status/footer bar
+- slash-command picker/help
+- approval modal
+- sudo prompt
+- clarify prompt
+- tool activity cards
+
+Do not start with:
+
+- mouse-heavy interaction
+- custom selection model
+- custom renderer internals
+- Claude-style terminal instrumentation
+
+Those can come later if real gaps appear.
+
+## Migration Phases
+
+## Phase 1: Headless Runtime Extraction
+
+Goal: make Hermes usable without `prompt_toolkit`.
+
+Work:
+
+- introduce a backend session/controller module
+- move PT-specific queues and rendering concerns out of agent flow
+- replace direct CLI callback assumptions with abstract request/response hooks
+- isolate slash-command execution from the PT shell
+- introduce a `platform="tui"` runtime path without forking core agent logic
+
+Exit criteria:
+
+- a non-PT backend can run a prompt, stream progress, request approval, and return a final response
+
+## Phase 2: Local UI Gateway
+
+Goal: expose the backend over stdio JSON-RPC.
+
+Work:
+
+- create a `ui_gateway` package or equivalent module group
+- model session lifecycle and event streaming
+- implement cancel/interrupt behavior
+- adapt terminal approval and sudo flow into transport messages
+- keep config, profile, and session storage identical to existing Hermes surfaces
+
+Exit criteria:
+
+- a minimal client can drive a full Hermes session over stdio without importing `cli.py`
+
+## Phase 3: Ink MVP
+
+Goal: ship a working Hermes TUI without `prompt_toolkit`.
+
+Work:
+
+- create a Node/TS package for the TUI
+- connect to the Python gateway
+- render transcript + composer + status
+- support approvals, clarify prompts, and slash commands
+- preserve interrupt-and-redirect behavior for active runs
+
+Exit criteria:
+
+- Hermes can be used end-to-end from the new TUI for normal chat and tool use
+
+## Phase 4: Feature Parity
+
+Goal: close the biggest regressions from the legacy CLI.
+
+Work:
+
+- port session picker/resume UX
+- port tool previews and long-running command status
+- port config-aware commands
+- port voice or explicitly defer it behind a non-blocking boundary
+
+Exit criteria:
+
+- daily-driver workflows no longer require the PT CLI
+
+## Phase 5: Cutover And Deletion
+
+Goal: make the new TUI the default interactive path.
+
+Work:
+
+- switch `hermes` interactive startup to the Ink client
+- keep legacy PT path only behind a temporary fallback flag if needed
+- delete PT-specific code after a short stabilization window
+
+Exit criteria:
+
+- `prompt_toolkit` is no longer part of the main interactive CLI
+
+## File-Level Refactor Targets
+
+Initial hot spots:
+
+- `cli.py`
+- `tools/terminal_tool.py`
+- `model_tools.py`
+- `run_agent.py`
+- `hermes_cli/commands.py`
+
+Expected pattern:
+
+- avoid importing PT code into the new backend path
+- move any UI-specific formatting behind protocol events or thin adapters
+
+## First Implementation Slices (PR Plan)
+
+Keep early PRs narrow and mergeable. Do not start with a large branch that rewrites `cli.py` end-to-end.
+
+1. `PR-1: headless session controller`
+ - add a transport-neutral controller around `AIAgent` for create/resume/prompt/interrupt/cancel
+ - no UI, no PT dependencies
+2. `PR-2: local ui gateway (stdio json-rpc)`
+ - add `ui_gateway` process entry
+ - implement protocol requests/events for one full prompt cycle
+3. `PR-3: ink shell bootstrap`
+ - add Node/TS package with gateway client
+ - render transcript + composer + status + streaming deltas
+4. `PR-4: interactive controls parity`
+ - approvals, sudo, clarify flows
+ - interrupt-and-redirect and command routing
+5. `PR-5: startup switch + fallback flag`
+ - add explicit opt-in startup flag for Ink path (`HERMES_EXPERIMENTAL_TUI=1` or equivalent)
+ - add CLI/config opt-in controls and `/tui` command entrypoint in legacy CLI
+ - keep PT path behind a temporary env/flag gate during stabilization
+6. `PR-6: parity hardening and PT deletion`
+ - close remaining UX gaps from legacy CLI
+ - remove PT path after stability window
+
+## Concrete File Plan
+
+Use fixed locations so contributors do not invent parallel structures.
+
+`PR-1` files:
+
+- add `tui_gateway/controller.py`
+- add `tui_gateway/session_state.py`
+- add `tui_gateway/events.py`
+- update `run_agent.py` only where callback wiring is needed
+- update `cli.py` only to call controller entry points in compatibility mode
+
+`PR-2` files:
+
+- add `tui_gateway/protocol.py`
+- add `tui_gateway/server.py`
+- add `tui_gateway/entry.py`
+- add `tui_gateway/__init__.py`
+- add `tests/tui_gateway/test_protocol.py`
+- add `tests/tui_gateway/test_server_flow.py`
+
+`PR-3` files:
+
+- add `ui-tui/package.json`
+- add `ui-tui/src/main.tsx`
+- add `ui-tui/src/gatewayClient.ts`
+- add `ui-tui/src/state/store.ts`
+- add `ui-tui/src/components/Transcript.tsx`
+- add `ui-tui/src/components/Composer.tsx`
+- add `ui-tui/src/components/StatusBar.tsx`
+
+`PR-4` files:
+
+- add `ui-tui/src/components/ApprovalModal.tsx`
+- add `ui-tui/src/components/SudoPrompt.tsx`
+- add `ui-tui/src/components/ClarifyPrompt.tsx`
+- update `tools/terminal_tool.py` to use gateway request/response adapters instead of PT-specific assumptions
+- add `tests/tui_gateway/test_permissions_roundtrip.py`
+
+`PR-5` files:
+
+- update `hermes_cli/main.py` startup selection for `--tui` and env/config flags
+- update `hermes_cli/commands.py` with `/tui` commands
+- update `cli.py` command dispatch to launch/attach behavior
+- add `tests/hermes_cli/test_tui_opt_in.py`
+
+`PR-6` files:
+
+- remove PT-only paths from `cli.py` once parity checks pass
+- remove obsolete PT wiring helpers
+- update docs and command help text
+
+If path names change, keep one module per role and avoid duplicate gateway implementations.
+
+## Protocol Envelope (v0)
+
+Use one JSON-RPC envelope shape for all gateway traffic.
+
+Request:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "req-123",
+ "method": "prompt.submit",
+ "params": {
+ "session_id": "sess-1",
+ "text": "hello"
+ }
+}
+```
+
+Event notification:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "event",
+ "params": {
+ "type": "message.delta",
+ "session_id": "sess-1",
+ "payload": {
+ "text": "hi"
+ }
+ }
+}
+```
+
+Error:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "req-123",
+ "error": {
+ "code": 4001,
+ "message": "session not found"
+ }
+}
+```
+
+Protocol rules:
+
+- all event ordering is per-session FIFO
+- ids are opaque strings
+- unknown event types are ignored by clients and logged
+- protocol version is pinned in `tui_gateway/protocol.py`
+
+## Acceptance Checks Per Phase
+
+Each phase should ship with explicit checks:
+
+- `runtime`
+ - prompt executes end-to-end without importing `prompt_toolkit`
+ - interrupt and cancel are deterministic
+- `state continuity`
+ - same `HERMES_HOME`, `config.yaml`, `state.db`, and profile behavior as existing Hermes surfaces
+- `commands`
+ - slash-command resolution uses shared registry (`hermes_cli/commands.py`)
+- `permissions`
+ - dangerous command approval, sudo prompt, and clarify prompt all round-trip through protocol events
+- `streaming`
+ - message/tool progress events stream incrementally; no UI-side polling loop for core turn output
+- `opt-in controls`
+ - `--tui`, env flag, config toggle, and `/tui` commands all resolve to the same launch behavior
+ - failures fall back to PT mode with explicit error output
+
+## Test Commands Per PR
+
+`PR-1`:
+
+- `python -m pytest tests/tui_gateway/test_controller.py -q`
+
+`PR-2`:
+
+- `python -m pytest tests/tui_gateway/test_protocol.py tests/tui_gateway/test_server_flow.py -q`
+
+`PR-3`:
+
+- `cd ui-tui && npm run build`
+- `cd ui-tui && npm run test`
+
+`PR-4`:
+
+- `python -m pytest tests/tui_gateway/test_permissions_roundtrip.py -q`
+- `cd ui-tui && npm run test`
+
+`PR-5`:
+
+- `python -m pytest tests/hermes_cli/test_tui_opt_in.py -q`
+
+`PR-6`:
+
+- `python -m pytest tests/ -q`
+
+## Opt-In UX Surface
+
+Expose TUI opt-in through user-facing TUI language, not transport language.
+
+Entry points:
+
+- startup flag: `hermes --tui`
+- env flag: `HERMES_EXPERIMENTAL_TUI=1`
+- config toggle: `display.experimental_tui: true`
+- slash command in legacy CLI:
+ - `/tui` (launch/attach)
+ - `/tui on` (persist opt-in)
+ - `/tui off` (disable auto-launch)
+ - `/tui status` (show mode + process/attach state)
+
+Behavior:
+
+- if `/tui` is called and the local TUI gateway is not running, start it and attach
+- if already running, attach/reuse session
+- on startup/attach failure, print clear error and stay in PT mode
+- `/tui off` disables future auto-launch; it does not terminate active sessions unless requested
+
+## Rollout And Rollback
+
+Rollout should be staged:
+
+1. internal opt-in (`HERMES_EXPERIMENTAL_TUI=1` or equivalent)
+2. external opt-in beta (still flag-gated, PT remains default)
+3. default-on with PT fallback still available, only after acceptance checks are green
+4. PT removal after a short stability window
+
+Rollback path must remain simple until PT deletion:
+
+- one switch to restore legacy interactive startup
+- no data migration required between TUI and PT modes (shared state model)
+
+## Main Risks
+
+1. `cli.py` currently owns more state than it appears to. Extraction will uncover hidden coupling.
+2. Approval and sudo flows are global/callback-driven today and need per-session protocol state.
+3. Long-running tool output may currently assume terminal-local behavior that has to be normalized before transport.
+4. Voice mode may carry PT assumptions and should be treated as optional during the first cut.
+5. If the frontend demands behavior beyond stock `Ink`, the team may need to introduce custom terminal primitives later.
+
+## Recommendation
+
+Start with stock `Ink` and a direct `tui_gateway` over stdio JSON-RPC.
+
+Do not:
+
+- refactor `prompt_toolkit` forward
+- route the terminal UI through the messaging gateway
+- begin by vendoring Claude Code's renderer
+
+The shortest path to a good Hermes TUI is:
+
+1. extract headless backend control flow
+2. expose it over stdio JSON-RPC
+3. build the TUI in `Ink`
+4. only customize deeper terminal behavior after real product pressure appears
+
+## Success Criteria
+
+This migration succeeds if Hermes can:
+
+- start an interactive session without `prompt_toolkit`
+- stream assistant and tool activity live into an `Ink` UI
+- handle approvals, clarify requests, sudo prompts, and interrupts cleanly
+- preserve the existing Python agent/tool runtime
+- preserve existing Hermes config, profile, and session continuity expectations
+- preserve shared slash-command semantics instead of inventing a second command surface
+- avoid adding new blocking UI-driven work into the prompt path
+- make the legacy PT shell deletable rather than permanent
diff --git a/tui_gateway/__init__.py b/tui_gateway/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py
new file mode 100644
index 000000000..72a4537f4
--- /dev/null
+++ b/tui_gateway/entry.py
@@ -0,0 +1,36 @@
+import json
+import sys
+
+from tui_gateway.server import handle_request, resolve_skin
+
+
+def _write(obj: dict):
+ sys.stdout.write(json.dumps(obj) + "\n")
+ sys.stdout.flush()
+
+
+def main():
+ _write({
+ "jsonrpc": "2.0",
+ "method": "event",
+ "params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}},
+ })
+
+ for raw in sys.stdin:
+ line = raw.strip()
+ if not line:
+ continue
+
+ try:
+ req = json.loads(line)
+ except json.JSONDecodeError:
+ _write({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None})
+ continue
+
+ resp = handle_request(req)
+ if resp is not None:
+ _write(resp)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
new file mode 100644
index 000000000..5a1c35069
--- /dev/null
+++ b/tui_gateway/server.py
@@ -0,0 +1,351 @@
+import json
+import os
+import subprocess
+import sys
+import threading
+import uuid
+from pathlib import Path
+
+from hermes_constants import get_hermes_home
+from hermes_cli.env_loader import load_hermes_dotenv
+
+_hermes_home = get_hermes_home()
+load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
+
+_sessions: dict[str, dict] = {}
+_methods: dict[str, callable] = {}
+_clarify_pending: dict[str, threading.Event] = {}
+_clarify_answers: dict[str, str] = {}
+
+
+# ── Wire ─────────────────────────────────────────────────────────────
+
+def _emit(event_type: str, sid: str, payload: dict | None = None):
+ params = {"type": event_type, "session_id": sid}
+ if payload:
+ params["payload"] = payload
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "method": "event", "params": params}) + "\n")
+ sys.stdout.flush()
+
+
+def _ok(req_id, result: dict) -> dict:
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
+
+
+def _err(req_id, code: int, msg: str) -> dict:
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": msg}}
+
+
+def method(name: str):
+ def dec(fn):
+ _methods[name] = fn
+ return fn
+ return dec
+
+
+def handle_request(req: dict) -> dict | None:
+ fn = _methods.get(req.get("method", ""))
+ if not fn:
+ return _err(req.get("id"), -32601, f"unknown method: {req.get('method')}")
+ return fn(req.get("id"), req.get("params", {}))
+
+
+# ── Helpers ──────────────────────────────────────────────────────────
+
+def resolve_skin() -> dict:
+ try:
+ import yaml
+ from hermes_cli.skin_engine import init_skin_from_config, get_active_skin
+ cfg_path = _hermes_home / "config.yaml"
+ cfg = {}
+ if cfg_path.exists():
+ with open(cfg_path) as f:
+ cfg = yaml.safe_load(f) or {}
+ init_skin_from_config(cfg)
+ skin = get_active_skin()
+ return {"name": skin.name, "colors": skin.colors, "branding": skin.branding}
+ except Exception:
+ return {}
+
+
+def _resolve_model() -> str:
+ env = os.environ.get("HERMES_MODEL", "")
+ if env:
+ return env
+ try:
+ import yaml
+ cfg_path = _hermes_home / "config.yaml"
+ if cfg_path.exists():
+ with open(cfg_path) as f:
+ m = (yaml.safe_load(f) or {}).get("model", "")
+ if isinstance(m, dict):
+ return m.get("default", "")
+ if isinstance(m, str):
+ return m
+ except Exception:
+ pass
+ return "anthropic/claude-sonnet-4"
+
+
+def _get_usage(agent) -> dict:
+ ga = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0)
+ return {
+ "input": ga("session_input_tokens", "session_prompt_tokens"),
+ "output": ga("session_output_tokens", "session_completion_tokens"),
+ "total": ga("session_total_tokens"),
+ "calls": ga("session_api_calls"),
+ }
+
+
+def _collect_session_info(agent) -> dict:
+ info: dict = {"model": getattr(agent, "model", ""), "tools": {}, "skills": {}}
+ try:
+ from model_tools import get_toolset_for_tool
+ for t in getattr(agent, "tools", []) or []:
+ name = t["function"]["name"]
+ info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append(name)
+ except Exception:
+ pass
+ try:
+ from hermes_cli.banner import get_available_skills
+ info["skills"] = get_available_skills()
+ except Exception:
+ pass
+ return info
+
+
+def _make_clarify_cb(sid: str):
+ def cb(question: str, choices: list | None) -> str:
+ rid = uuid.uuid4().hex[:8]
+ ev = threading.Event()
+ _clarify_pending[rid] = ev
+ _emit("clarify.request", sid, {"request_id": rid, "question": question, "choices": choices})
+ ev.wait(timeout=300)
+ _clarify_pending.pop(rid, None)
+ return _clarify_answers.pop(rid, "")
+ return cb
+
+
+def _register_approval_notify(sid: str, session_key: str):
+ try:
+ from tools.approval import register_gateway_notify
+ register_gateway_notify(session_key, lambda data: _emit("approval.request", sid, data))
+ except Exception:
+ pass
+
+
+# ── Methods ──────────────────────────────────────────────────────────
+
+@method("session.create")
+def _(req_id, params: dict) -> dict:
+ sid = uuid.uuid4().hex[:8]
+ session_key = f"tui-{sid}"
+
+ os.environ["HERMES_SESSION_KEY"] = session_key
+ os.environ["HERMES_INTERACTIVE"] = "1"
+
+ try:
+ from run_agent import AIAgent
+ agent = AIAgent(
+ model=_resolve_model(),
+ quiet_mode=True,
+ platform="tui",
+ tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name}),
+ tool_complete_callback=lambda tc_id, name, args, result: _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}),
+ tool_progress_callback=lambda name, preview, args: _emit("tool.progress", sid, {"name": name, "preview": preview}),
+ tool_gen_callback=lambda name: _emit("tool.generating", sid, {"name": name}),
+ thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}),
+ reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}),
+ status_callback=lambda text: _emit("status.update", sid, {"text": text}),
+ clarify_callback=_make_clarify_cb(sid),
+ )
+ _sessions[sid] = {"agent": agent, "session_key": session_key}
+ except Exception as e:
+ return _err(req_id, 5000, f"agent init failed: {e}")
+
+ _register_approval_notify(sid, session_key)
+
+ from tools.approval import load_permanent_allowlist
+ load_permanent_allowlist()
+
+ _emit("session.info", sid, _collect_session_info(agent))
+ return _ok(req_id, {"session_id": sid})
+
+
+@method("prompt.submit")
+def _(req_id, params: dict) -> dict:
+ sid, text = params.get("session_id", ""), params.get("text", "")
+ session = _sessions.get(sid)
+ if not session:
+ return _err(req_id, 4001, "session not found")
+
+ agent = session["agent"]
+ _emit("message.start", sid)
+
+ def run():
+ try:
+ result = agent.run_conversation(
+ text,
+ stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}),
+ )
+
+ if isinstance(result, dict):
+ final = result.get("final_response", "")
+ status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete"
+ _emit("message.complete", sid, {
+ "text": final or "",
+ "usage": _get_usage(agent),
+ "status": status,
+ })
+ else:
+ _emit("message.complete", sid, {"text": str(result), "usage": _get_usage(agent), "status": "complete"})
+
+ except Exception as e:
+ _emit("error", sid, {"message": str(e)})
+
+ threading.Thread(target=run, daemon=True).start()
+ return _ok(req_id, {"status": "streaming"})
+
+
+@method("clarify.respond")
+def _(req_id, params: dict) -> dict:
+ rid = params.get("request_id", "")
+ ev = _clarify_pending.get(rid)
+ if not ev:
+ return _err(req_id, 4003, "no pending clarify request")
+ _clarify_answers[rid] = params.get("answer", "")
+ ev.set()
+ return _ok(req_id, {"status": "ok"})
+
+
+@method("approval.respond")
+def _(req_id, params: dict) -> dict:
+ sid = params.get("session_id", "")
+ choice = params.get("choice", "deny")
+
+ session = _sessions.get(sid)
+ if not session:
+ return _err(req_id, 4001, "session not found")
+
+ try:
+ from tools.approval import resolve_gateway_approval
+ n = resolve_gateway_approval(session["session_key"], choice, resolve_all=params.get("all", False))
+ return _ok(req_id, {"resolved": n})
+ except Exception as e:
+ return _err(req_id, 5004, str(e))
+
+
+@method("session.usage")
+def _(req_id, params: dict) -> dict:
+ session = _sessions.get(params.get("session_id", ""))
+ if not session:
+ return _err(req_id, 4001, "session not found")
+ return _ok(req_id, _get_usage(session["agent"]))
+
+
+@method("session.history")
+def _(req_id, params: dict) -> dict:
+ session = _sessions.get(params.get("session_id", ""))
+ if not session:
+ return _err(req_id, 4001, "session not found")
+ history = getattr(session["agent"], "conversation_history", [])
+ return _ok(req_id, {"count": len(history)})
+
+
+@method("session.undo")
+def _(req_id, params: dict) -> dict:
+ session = _sessions.get(params.get("session_id", ""))
+ if not session:
+ return _err(req_id, 4001, "session not found")
+ history = getattr(session["agent"], "conversation_history", [])
+ removed = 0
+ while history and history[-1].get("role") in ("assistant", "tool"):
+ history.pop(); removed += 1
+ if history and history[-1].get("role") == "user":
+ history.pop(); removed += 1
+ return _ok(req_id, {"removed": removed})
+
+
+@method("session.compress")
+def _(req_id, params: dict) -> dict:
+ session = _sessions.get(params.get("session_id", ""))
+ if not session:
+ return _err(req_id, 4001, "session not found")
+ agent = session["agent"]
+ try:
+ if hasattr(agent, "compress_context"):
+ agent.compress_context()
+ return _ok(req_id, {"status": "compressed", "usage": _get_usage(agent)})
+ except Exception as e:
+ return _err(req_id, 5005, str(e))
+
+
+@method("config.set")
+def _(req_id, params: dict) -> dict:
+ key, value = params.get("key", ""), params.get("value", "")
+
+ if key == "model":
+ os.environ["HERMES_MODEL"] = value
+ return _ok(req_id, {"key": key, "value": value})
+
+ if key == "skin":
+ try:
+ import yaml
+ cfg_path = _hermes_home / "config.yaml"
+ cfg = {}
+ if cfg_path.exists():
+ with open(cfg_path) as f:
+ cfg = yaml.safe_load(f) or {}
+ cfg["skin"] = value
+ with open(cfg_path, "w") as f:
+ yaml.safe_dump(cfg, f)
+ return _ok(req_id, {"key": key, "value": value})
+ except Exception as e:
+ return _err(req_id, 5001, str(e))
+
+ return _err(req_id, 4002, f"unknown config key: {key}")
+
+
+@method("session.interrupt")
+def _(req_id, params: dict) -> dict:
+ session = _sessions.get(params.get("session_id", ""))
+ if not session:
+ return _err(req_id, 4001, "session not found")
+
+ if hasattr(session["agent"], "interrupt"):
+ session["agent"].interrupt()
+
+ for rid, ev in list(_clarify_pending.items()):
+ _clarify_answers[rid] = ""
+ ev.set()
+
+ try:
+ from tools.approval import resolve_gateway_approval
+ resolve_gateway_approval(session["session_key"], "deny", resolve_all=True)
+ except Exception:
+ pass
+
+ return _ok(req_id, {"status": "interrupted"})
+
+
+@method("shell.exec")
+def _(req_id, params: dict) -> dict:
+ cmd = params.get("command", "")
+ if not cmd:
+ return _err(req_id, 4004, "empty command")
+
+ try:
+ from tools.approval import detect_dangerous_command
+ is_dangerous, _, description = detect_dangerous_command(cmd)
+ if is_dangerous:
+ return _err(req_id, 4005, f"blocked: {description}. Use the agent for dangerous commands (it has approval flow).")
+ except ImportError:
+ pass
+
+ try:
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd())
+ return _ok(req_id, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode})
+ except subprocess.TimeoutExpired:
+ return _err(req_id, 5002, "command timed out (30s)")
+ except Exception as e:
+ return _err(req_id, 5003, str(e))
diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json
new file mode 100644
index 000000000..a5b78523c
--- /dev/null
+++ b/ui-tui/package-lock.json
@@ -0,0 +1,1213 @@
+{
+ "name": "hermes-tui",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hermes-tui",
+ "version": "0.0.1",
+ "dependencies": {
+ "ink": "^6.8.0",
+ "ink-text-input": "^6.0.0",
+ "react": "^19.2.4"
+ },
+ "devDependencies": {
+ "@types/node": "^25.5.0",
+ "@types/react": "^19.2.14",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+ },
+ "node_modules/@alcalzone/ansi-tokenize": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz",
+ "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.5.tgz",
+ "integrity": "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.5.tgz",
+ "integrity": "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.5.tgz",
+ "integrity": "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.5.tgz",
+ "integrity": "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.5.tgz",
+ "integrity": "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.5.tgz",
+ "integrity": "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.5.tgz",
+ "integrity": "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.5.tgz",
+ "integrity": "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.5.tgz",
+ "integrity": "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.5.tgz",
+ "integrity": "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.5.tgz",
+ "integrity": "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.5.tgz",
+ "integrity": "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.5.tgz",
+ "integrity": "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.5.tgz",
+ "integrity": "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.5.tgz",
+ "integrity": "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.5.tgz",
+ "integrity": "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.5.tgz",
+ "integrity": "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.5.tgz",
+ "integrity": "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.5.tgz",
+ "integrity": "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.5.tgz",
+ "integrity": "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.5.tgz",
+ "integrity": "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.5.tgz",
+ "integrity": "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.5.tgz",
+ "integrity": "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.5.tgz",
+ "integrity": "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.5.tgz",
+ "integrity": "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.5.tgz",
+ "integrity": "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
+ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "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",
+ "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",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "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/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "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",
+ "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": "5.2.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
+ "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
+ "license": "MIT",
+ "dependencies": {
+ "slice-ansi": "^8.0.0",
+ "string-width": "^8.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate/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",
+ "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/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/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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",
+ "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",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.5.tgz",
+ "integrity": "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.5",
+ "@esbuild/android-arm": "0.27.5",
+ "@esbuild/android-arm64": "0.27.5",
+ "@esbuild/android-x64": "0.27.5",
+ "@esbuild/darwin-arm64": "0.27.5",
+ "@esbuild/darwin-x64": "0.27.5",
+ "@esbuild/freebsd-arm64": "0.27.5",
+ "@esbuild/freebsd-x64": "0.27.5",
+ "@esbuild/linux-arm": "0.27.5",
+ "@esbuild/linux-arm64": "0.27.5",
+ "@esbuild/linux-ia32": "0.27.5",
+ "@esbuild/linux-loong64": "0.27.5",
+ "@esbuild/linux-mips64el": "0.27.5",
+ "@esbuild/linux-ppc64": "0.27.5",
+ "@esbuild/linux-riscv64": "0.27.5",
+ "@esbuild/linux-s390x": "0.27.5",
+ "@esbuild/linux-x64": "0.27.5",
+ "@esbuild/netbsd-arm64": "0.27.5",
+ "@esbuild/netbsd-x64": "0.27.5",
+ "@esbuild/openbsd-arm64": "0.27.5",
+ "@esbuild/openbsd-x64": "0.27.5",
+ "@esbuild/openharmony-arm64": "0.27.5",
+ "@esbuild/sunos-x64": "0.27.5",
+ "@esbuild/win32-arm64": "0.27.5",
+ "@esbuild/win32-ia32": "0.27.5",
+ "@esbuild/win32-x64": "0.27.5"
+ }
+ },
+ "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/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "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/get-tsconfig": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "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": "6.8.0",
+ "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz",
+ "integrity": "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@alcalzone/ansi-tokenize": "^0.2.4",
+ "ansi-escapes": "^7.3.0",
+ "ansi-styles": "^6.2.1",
+ "auto-bind": "^5.0.1",
+ "chalk": "^5.6.0",
+ "cli-boxes": "^3.0.0",
+ "cli-cursor": "^4.0.0",
+ "cli-truncate": "^5.1.1",
+ "code-excerpt": "^4.0.0",
+ "es-toolkit": "^1.39.10",
+ "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": "^8.0.0",
+ "stack-utils": "^2.0.6",
+ "string-width": "^8.1.1",
+ "terminal-size": "^4.0.1",
+ "type-fest": "^5.4.1",
+ "widest-line": "^6.0.0",
+ "wrap-ansi": "^9.0.0",
+ "ws": "^8.18.0",
+ "yoga-layout": "~3.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@types/react": ">=19.0.0",
+ "react": ">=19.0.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",
+ "dependencies": {
+ "chalk": "^5.3.0",
+ "type-fest": "^4.18.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "ink": ">=5",
+ "react": ">=18"
+ }
+ },
+ "node_modules/ink/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",
+ "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/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)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "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",
+ "bin": {
+ "is-in-ci": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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",
+ "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",
+ "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",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "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/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "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",
+ "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/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/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"
+ },
+ "node_modules/slice-ansi": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
+ "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "is-fullwidth-code-point": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "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": "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/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/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",
+ "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",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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",
+ "dependencies": {
+ "string-width": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/widest-line/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",
+ "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/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/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",
+ "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"
+ }
+ }
+}
diff --git a/ui-tui/package.json b/ui-tui/package.json
new file mode 100644
index 000000000..52c400d42
--- /dev/null
+++ b/ui-tui/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "hermes-tui",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx --watch src/main.tsx",
+ "start": "tsx src/main.tsx",
+ "build": "tsc",
+ "test": "echo 'no tests yet'"
+ },
+ "dependencies": {
+ "ink": "^6.8.0",
+ "ink-text-input": "^6.0.0",
+ "react": "^19.2.4"
+ },
+ "devDependencies": {
+ "@types/node": "^25.5.0",
+ "@types/react": "^19.2.14",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/ui-tui/src/altScreen.tsx b/ui-tui/src/altScreen.tsx
new file mode 100644
index 000000000..f6c74888b
--- /dev/null
+++ b/ui-tui/src/altScreen.tsx
@@ -0,0 +1,29 @@
+import { useEffect, type PropsWithChildren } from 'react'
+import { Box, useStdout } from 'ink'
+
+const ENTER = '\x1b[?1049h\x1b[2J\x1b[H'
+const LEAVE = '\x1b[?1049l'
+
+export function AltScreen({ children }: PropsWithChildren) {
+ const { stdout } = useStdout()
+ const rows = stdout?.rows ?? 24
+ const cols = stdout?.columns ?? 80
+
+ useEffect(() => {
+ process.stdout.write(ENTER)
+
+ const leave = () => process.stdout.write(LEAVE)
+ process.on('exit', leave)
+
+ return () => {
+ leave()
+ process.off('exit', leave)
+ }
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts
new file mode 100644
index 000000000..3d52c9137
--- /dev/null
+++ b/ui-tui/src/banner.ts
@@ -0,0 +1,43 @@
+import type { ThemeColors } from './theme.js'
+
+type Line = [string, string]
+
+const LOGO_ART = [
+ '██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
+ '██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
+ '███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
+ '██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
+ '██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
+ '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ',
+]
+
+const CADUCEUS_ART = [
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀',
+ '⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀',
+ '⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+ '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
+]
+
+const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
+const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
+
+function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] {
+ const palette = [c.gold, c.amber, c.bronze, c.dim]
+ return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text])
+}
+
+export const LOGO_WIDTH = 98
+
+export const logo = (c: ThemeColors) => colorize(LOGO_ART, LOGO_GRADIENT, c)
+export const caduceus = (c: ThemeColors) => colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts
new file mode 100644
index 000000000..95ae06b1c
--- /dev/null
+++ b/ui-tui/src/gatewayClient.ts
@@ -0,0 +1,72 @@
+import { spawn, type ChildProcess } from 'node:child_process'
+import { createInterface } from 'node:readline'
+import { EventEmitter } from 'node:events'
+import { resolve } from 'node:path'
+
+export interface GatewayEvent {
+ type: string
+ session_id?: string
+ payload?: Record
+}
+
+interface Pending {
+ resolve: (v: unknown) => void
+ reject: (e: Error) => void
+}
+
+export class GatewayClient extends EventEmitter {
+ private proc: ChildProcess | null = null
+ private reqId = 0
+ private pending = new Map()
+
+ start() {
+ const root = resolve(import.meta.dirname, '../../')
+
+ this.proc = spawn(
+ process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'),
+ ['-m', 'tui_gateway.entry'],
+ { cwd: root, stdio: ['pipe', 'pipe', 'inherit'] },
+ )
+
+ createInterface({ input: this.proc.stdout! }).on('line', (raw) => {
+ try { this.dispatch(JSON.parse(raw)) } catch {}
+ })
+
+ this.proc.on('exit', (code) => this.emit('exit', code))
+ }
+
+ private dispatch(msg: Record) {
+ const id = msg.id as string | undefined
+ const p = id ? this.pending.get(id) : undefined
+
+ if (p) {
+ this.pending.delete(id!)
+ msg.error
+ ? p.reject(new Error((msg.error as any).message))
+ : p.resolve(msg.result)
+ return
+ }
+
+ if (msg.method === 'event')
+ this.emit('event', msg.params as GatewayEvent)
+ }
+
+ request(method: string, params: Record = {}): Promise {
+ const id = `r${++this.reqId}`
+
+ this.proc!.stdin!.write(
+ JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
+ )
+
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject })
+
+ setTimeout(() => {
+ if (this.pending.delete(id))
+ reject(new Error(`timeout: ${method}`))
+ }, 30_000)
+ })
+ }
+
+ kill() { this.proc?.kill() }
+}
diff --git a/ui-tui/src/main.js b/ui-tui/src/main.js
new file mode 100644
index 000000000..892853bc9
--- /dev/null
+++ b/ui-tui/src/main.js
@@ -0,0 +1,46 @@
+"use strict";
+var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
+ if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
+ if (ar || !(i in from)) {
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
+ ar[i] = from[i];
+ }
+ }
+ return to.concat(ar || Array.prototype.slice.call(from));
+};
+var _a;
+Object.defineProperty(exports, "__esModule", { value: true });
+var react_1 = require("react");
+var ink_1 = require("ink");
+var ink_text_input_1 = require("ink-text-input");
+function App() {
+ var _a = (0, react_1.useState)(''), input = _a[0], setInput = _a[1];
+ var _b = (0, react_1.useState)([]), messages = _b[0], setMessages = _b[1];
+ var handleSubmit = function (value) {
+ if (!value.trim())
+ return;
+ setMessages(function (prev) { return __spreadArray(__spreadArray([], prev, true), ["> ".concat(value), "[echo] ".concat(value)], false); });
+ setInput('');
+ };
+ return (
+
+ hermes
+ (ink proof-of-concept)
+
+
+
+ {messages.map(function (msg, i) { return ({msg}); })}
+
+
+
+ {'> '}
+
+
+ );
+}
+var isTTY = (_a = process.stdin.isTTY) !== null && _a !== void 0 ? _a : false;
+if (!isTTY) {
+ console.log('hermes-tui: ink loaded, no TTY attached (run in a real terminal)');
+ process.exit(0);
+}
+(0, ink_1.render)();
diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx
new file mode 100644
index 000000000..fa9d1bee4
--- /dev/null
+++ b/ui-tui/src/main.tsx
@@ -0,0 +1,1073 @@
+'use strict'
+
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
+import { render, Box, Text, useApp, useStdout, useInput } from 'ink'
+import TextInput from 'ink-text-input'
+
+import { GatewayClient, type GatewayEvent } from './gatewayClient.js'
+import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
+import { logo, caduceus, LOGO_WIDTH } from './banner.js'
+import { AltScreen } from './altScreen.js'
+
+
+type Role = 'user' | 'assistant' | 'system' | 'tool'
+
+interface Msg { role: Role; text: string }
+interface SessionInfo { model: string; tools: Record; skills: Record }
+interface ActiveTool { id: string; name: string }
+interface ClarifyReq { requestId: string; question: string; choices: string[] | null }
+interface ApprovalReq { command: string; description: string }
+interface Usage { input: number; output: number; total: number; calls: number }
+
+const ZERO: Usage = { input: 0, output: 0, total: 0, calls: 0 }
+const MAX_CTX = 128_000
+
+const COMMANDS: [string, string][] = [
+ ['/help', 'commands & hotkeys'],
+ ['/model', 'switch model'],
+ ['/skin', 'change theme'],
+ ['/clear', 'reset chat'],
+ ['/new', 'new session'],
+ ['/undo', 'drop last exchange'],
+ ['/retry', 'resend last message'],
+ ['/compact', 'toggle compact [focus]'],
+ ['/cost', 'token usage stats'],
+ ['/copy', 'copy last response'],
+ ['/context', 'context window info'],
+ ['/compress', 'compress context'],
+ ['/skills', 'list skills'],
+ ['/config', 'show config'],
+ ['/status', 'session info'],
+ ['/quit', 'exit hermes'],
+]
+
+const HOTKEYS: [string, string][] = [
+ ['Ctrl+C', 'interrupt / clear / exit'],
+ ['Ctrl+D', 'exit'],
+ ['Ctrl+L', 'clear screen'],
+ ['↑/↓', 'queue edit (if queued) / input history'],
+ ['PgUp/PgDn','scroll messages'],
+ ['Ctrl+J', 'newline in input'],
+ ['Esc', 'clear input'],
+ ['\\+Enter', 'multi-line continuation'],
+ ['!cmd', 'run shell command'],
+ ['{!cmd}', 'interpolate shell output inline'],
+]
+
+const PLACEHOLDERS = [
+ 'Ask me anything…', 'Try "explain this codebase"', 'Try "write a test for…"',
+ 'Try "refactor the auth module"', 'Try "/help" for commands',
+ 'Try "fix the lint errors"', 'Try "how does the config loader work?"',
+]
+
+const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
+
+const FACES = [
+ '(。•́︿•̀。)', '(◔_◔)', '(¬‿¬)', '( •_•)>⌐■-■', '(⌐■_■)',
+ '(´・_・`)', '◉_◉', '(°ロ°)', '( ˘⌣˘)♡', 'ヽ(>∀<☆)☆',
+ '٩(๑❛ᴗ❛๑)۶', '(⊙_⊙)', '(¬_¬)', '( ͡° ͜ʖ ͡°)', 'ಠ_ಠ',
+]
+
+const VERBS = [
+ 'pondering', 'contemplating', 'musing', 'cogitating', 'ruminating',
+ 'deliberating', 'mulling', 'reflecting', 'processing', 'reasoning',
+ 'analyzing', 'computing', 'synthesizing', 'formulating', 'brainstorming',
+]
+
+const TOOL_VERBS: Record = {
+ read_file: '📖 reading', write_file: '✏️ writing', search_code: '🔍 searching',
+ run_command: '⚙️ running', execute_code: '⚡ executing', list_files: '📂 listing',
+ web_search: '🌐 searching', create_file: '📝 creating', delete_file: '🗑️ deleting',
+ memory: '🧠 remembering', clarify: '❓ asking', delegate_task: '🤖 delegating',
+ browser: '🌐 browsing', terminal: '💻 terminal', patch: '🩹 patching',
+ search_files: '🔍 searching', image_generate: '🎨 generating',
+}
+
+const ROLE: Record [string, string, string]> = {
+ user: t => [t.brand.prompt + ' ', t.color.label, t.color.label],
+ assistant: t => [t.brand.tool + ' ', t.color.bronze, t.color.cornsilk],
+ system: t => ['! ', t.color.error, t.color.error],
+ tool: t => ['⚡ ', t.color.dim, t.color.dim],
+}
+
+const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]!
+const fmtK = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
+const flat = (r: Record) => Object.values(r).flat()
+const estimateRows = (text: string, w: number) =>
+ text.split('\n').reduce((s, l) => s + Math.max(1, Math.ceil(Math.max(1, l.length) / w)), 0)
+const compactPreview = (s: string, max: number) => {
+ const one = s.replace(/\s+/g, ' ').trim()
+ if (!one) return ''
+ return one.length > max ? one.slice(0, max - 1) + '…' : one
+}
+const PLACEHOLDER = pick(PLACEHOLDERS)
+
+
+// ── Components ──────────────────────────────────────────────────────
+
+function ArtLines({ lines }: { lines: [string, string][] }) {
+ return <>{lines.map(([c, text], i) => {text})}>
+}
+
+function Banner({ t }: { t: Theme }) {
+ const cols = useStdout().stdout?.columns ?? 80
+
+ return (
+
+ {cols >= LOGO_WIDTH
+ ?
+ : {t.brand.icon} NOUS HERMES}
+
+
+
+
+ {t.brand.icon} Nous Research
+ · Messenger of the Digital Gods
+
+
+ )
+}
+
+function truncLine(pfx: string, items: string[], max: number): string {
+ let line = ''
+ for (const item of items.sort()) {
+ const next = line ? `${line}, ${item}` : item
+ if (pfx.length + next.length > max)
+ return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …`
+ line = next
+ }
+ return line
+}
+
+function SessionPanel({ t, info }: { t: Theme; info: SessionInfo }) {
+ const cols = useStdout().stdout?.columns ?? 100
+ const wide = cols >= 90
+ const w = wide ? cols - 46 : cols - 10
+ const strip = (s: string) => s.endsWith('_tools') ? s.slice(0, -6) : s
+
+ const section = (title: string, data: Record, max = 8) => {
+ const entries = Object.entries(data).sort()
+ const shown = entries.slice(0, max)
+ const overflow = entries.length - max
+
+ return (
+
+ Available {title}
+
+ {shown.map(([k, vs]) => (
+
+ {strip(k)}:
+ {truncLine(strip(k) + ': ', vs, w)}
+
+ ))}
+
+ {overflow > 0 && (
+ (and {overflow} more…)
+ )}
+
+ )
+ }
+
+ return (
+
+
+ {wide && (
+
+
+
+ Nous Research
+
+ )}
+
+
+ {t.brand.icon} {t.brand.name}
+
+ {section('Tools', info.tools)}
+ {section('Skills', info.skills)}
+
+
+
+
+ {flat(info.tools).length} tools
+ {' · '}{flat(info.skills).length} skills
+ {' · '}/help for commands
+
+
+
+ {info.model.split('/').pop()}
+ {' · '}Ctrl+C to interrupt
+
+
+
+ )
+}
+
+function CommandPalette({ t, filter }: { t: Theme; filter: string }) {
+ const m = COMMANDS.filter(([cmd]) => cmd.startsWith(filter))
+ if (!m.length) return null
+
+ return (
+
+ {m.map(([cmd, desc]) => (
+
+ {cmd}
+ — {desc}
+
+ ))}
+
+ )
+}
+
+function Thinking({ t, tools, reasoning }: { t: Theme; tools: ActiveTool[]; reasoning: string }) {
+ const [frame, setFrame] = useState(0)
+ const [verb] = useState(() => pick(VERBS))
+ const [face] = useState(() => pick(FACES))
+
+ useEffect(() => {
+ const id = setInterval(() => setFrame(f => (f + 1) % SPINNER.length), 80)
+ return () => clearInterval(id)
+ }, [])
+
+ return (
+
+ {tools.length
+ ? tools.map(tool => (
+
+ {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}…
+
+ ))
+ : {SPINNER[frame]} {face} {verb}…}
+
+ {reasoning && (
+
+ {' 💭 '}{reasoning.slice(-120).replace(/\n/g, ' ')}
+
+ )}
+
+ )
+}
+
+
+// ── Interactive Prompts ─────────────────────────────────────────────
+
+function ClarifyPrompt({ t, req, onAnswer }: { t: Theme; req: ClarifyReq; onAnswer: (s: string) => void }) {
+ const [sel, setSel] = useState(0)
+ const [custom, setCustom] = useState('')
+ const [typing, setTyping] = useState(false)
+ const choices = req.choices ?? []
+
+ useInput((ch, key) => {
+ if (typing) return
+ if (key.upArrow && sel > 0) setSel(s => s - 1)
+ if (key.downArrow && sel < choices.length) setSel(s => s + 1)
+ if (key.return) {
+ if (sel === choices.length) setTyping(true)
+ else if (choices[sel]) onAnswer(choices[sel]!)
+ }
+ const n = parseInt(ch)
+ if (n >= 1 && n <= choices.length) onAnswer(choices[n - 1]!)
+ })
+
+ if (typing || !choices.length)
+ return (
+
+ ❓ {req.question}
+
+ {'> '}
+
+
+
+ )
+
+ const row = (i: number, label: string) => (
+
+ {sel === i ? '▸ ' : ' '}
+ {i + 1}. {label}
+
+ )
+
+ return (
+
+ ❓ {req.question}
+ {choices.map((c, i) => row(i, c))}
+ {row(choices.length, 'Other (type your answer)')}
+ ↑/↓ select · Enter confirm · 1-{choices.length} quick pick
+
+ )
+}
+
+function ApprovalPrompt({ t, req, onChoice }: { t: Theme; req: ApprovalReq; onChoice: (s: string) => void }) {
+ const [sel, setSel] = useState(3)
+ const opts = ['once', 'session', 'always', 'deny'] as const
+
+ useInput((ch, key) => {
+ if (key.upArrow && sel > 0) setSel(s => s - 1)
+ if (key.downArrow && sel < 3) setSel(s => s + 1)
+ if (key.return) onChoice(opts[sel]!)
+ if (ch === 'o') onChoice('once')
+ if (ch === 's') onChoice('session')
+ if (ch === 'a') onChoice('always')
+ if (ch === 'd' || key.escape) onChoice('deny')
+ })
+
+ return (
+
+ ⚠️ DANGEROUS COMMAND: {req.description}
+ {req.command}
+
+ {opts.map((o, i) => (
+
+ {sel === i ? '▸ ' : ' '}
+
+ [{o[0]}] {o === 'once' ? 'Allow once' : o === 'session' ? 'Allow this session' : o === 'always' ? 'Always allow' : 'Deny'}
+
+
+ ))}
+ ↑/↓ select · Enter confirm · o/s/a/d quick pick
+
+ )
+}
+
+
+// ── Markdown ────────────────────────────────────────────────────────
+
+function Md({ t, text, compact }: { t: Theme; text: string; compact?: boolean }) {
+ const lines = text.split('\n')
+ const nodes: React.ReactNode[] = []
+ let i = 0
+
+ while (i < lines.length) {
+ const line = lines[i]!
+ const k = nodes.length
+
+ if (compact && !line.trim()) { i++; continue }
+
+ if (line.startsWith('```')) {
+ const lang = line.slice(3).trim()
+ const block: string[] = []
+ for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++)
+ block.push(lines[i]!)
+ i++
+ nodes.push(
+
+ {lang && {'─ ' + lang}}
+ {block.map((l, j) => {l})}
+ ,
+ )
+ continue
+ }
+
+ const hm = line.match(/^#{1,3}\s+(.*)/)
+ if (hm) { nodes.push({hm[1]}); i++; continue }
+
+ const bm = line.match(/^\s*[-*]\s(.*)/)
+ if (bm) { nodes.push( • ); i++; continue }
+
+ const nm = line.match(/^\s*(\d+)\.\s(.*)/)
+ if (nm) { nodes.push( {nm[1]}. ); i++; continue }
+
+ nodes.push()
+ i++
+ }
+
+ return {nodes}
+}
+
+function MdInline({ t, text }: { t: Theme; text: string }) {
+ const parts: React.ReactNode[] = []
+ const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g
+ let last = 0
+ let m: RegExpExecArray | null
+
+ while ((m = re.exec(text)) !== null) {
+ if (m.index > last)
+ parts.push({text.slice(last, m.index)})
+
+ if (m[2] && m[3]) {
+ parts.push({m[2]})
+ } else if (m[4]) {
+ parts.push({m[4]})
+ } else if (m[5]) {
+ parts.push({m[5]})
+ } else if (m[6]) {
+ parts.push({m[6]})
+ } else if (m[7]) {
+ parts.push({m[7]})
+ }
+
+ last = m.index + m[0].length
+ }
+
+ if (last < text.length)
+ parts.push({text.slice(last)})
+
+ return {parts.length ? parts : {text}}
+}
+
+
+// ── Message ─────────────────────────────────────────────────────────
+
+function MessageLine({ t, msg, compact }: { t: Theme; msg: Msg; compact?: boolean }) {
+ const [, pc, tc] = ROLE[msg.role](t)
+ const glyph = msg.role === 'user' ? t.brand.prompt
+ : msg.role === 'assistant' ? t.brand.tool
+ : msg.role === 'tool' ? '⚡' : '!'
+
+ return (
+
+ {glyph}
+
+ {msg.role === 'assistant'
+ ?
+ : {msg.text}}
+
+ )
+}
+
+
+// ── App ─────────────────────────────────────────────────────────────
+
+function App({ gw }: { gw: GatewayClient }) {
+ const { exit } = useApp()
+ const { stdout } = useStdout()
+ const cols = stdout?.columns ?? 80
+
+ const [input, setInput] = useState('')
+ const [inputBuf, setInputBuf] = useState([])
+ const [messages, setMessages] = useState([])
+ const [status, setStatus] = useState('summoning hermes…')
+ const [sid, setSid] = useState(null)
+ const [theme, setTheme] = useState(DEFAULT_THEME)
+ const [info, setInfo] = useState(null)
+ const [thinking, setThinking] = useState(false)
+ const [tools, setTools] = useState([])
+ const [busy, setBusy] = useState(false)
+ const [compact, setCompact] = useState(false)
+ const [usage, setUsage] = useState(ZERO)
+ const [clarify, setClarify] = useState(null)
+ const [approval, setApproval] = useState(null)
+ const [reasoning, setReasoning] = useState('')
+ const [lastUserMsg, setLastUserMsg] = useState('')
+ const [queueEditIdx, setQueueEditIdx] = useState(null)
+ const [scrollOffset, setScrollOffset] = useState(0)
+
+ const buf = useRef('')
+ const stickyRef = useRef(true)
+ const queueRef = useRef([])
+ const historyRef = useRef([])
+ const historyDraftRef = useRef('')
+ const [historyIdx, setHistoryIdx] = useState(null)
+ const queueEditIdxRef = useRef(null)
+ const [queuedDisplay, setQueuedDisplay] = useState([])
+ const lastEmptySubmitAt = useRef(0)
+ const empty = !messages.length
+
+ const setQueueEdit = (idx: number | null) => {
+ queueEditIdxRef.current = idx
+ setQueueEditIdx(idx)
+ }
+
+ const enqueue = (text: string) => {
+ queueRef.current.push(text)
+ setQueuedDisplay([...queueRef.current])
+ }
+
+ const pushHistory = (text: string) => {
+ const t = text.trim()
+ if (!t) return
+ const h = historyRef.current
+ if (h.at(-1) !== t) h.push(t)
+ }
+
+ const replaceQueued = (idx: number, text: string) => {
+ if (idx < 0 || idx >= queueRef.current.length) return
+ queueRef.current[idx] = text
+ setQueuedDisplay([...queueRef.current])
+ }
+
+ const removeQueued = (idx: number) => {
+ if (idx < 0 || idx >= queueRef.current.length) return
+ queueRef.current = queueRef.current.filter((_, i) => i !== idx)
+ setQueuedDisplay([...queueRef.current])
+ }
+
+ const dequeue = () => {
+ const [next, ...rest] = queueRef.current
+ queueRef.current = rest
+ setQueuedDisplay([...rest])
+ return next
+ }
+
+ useEffect(() => { if (stickyRef.current) setScrollOffset(0) }, [messages.length])
+
+ const termRows = stdout?.rows ?? 24
+ const chromeRows = 2 + (empty ? 0 : 2) + (thinking ? 2 : 0) + 2
+ const msgBudget = Math.max(3, termRows - chromeRows)
+
+ const visibleSlice = useMemo(() => {
+ if (!messages.length) return { start: 0, end: 0, above: 0 }
+ const end = Math.max(0, messages.length - scrollOffset)
+ const w = Math.max(20, cols - 5)
+ let budget = msgBudget
+ let start = end
+ for (let i = end - 1; i >= 0 && budget > 0; i--) {
+ const margin = messages[i]!.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0
+ budget -= margin + estimateRows(messages[i]!.text, w)
+ if (budget >= 0) start = i
+ }
+ return { start, end, above: start }
+ }, [messages, scrollOffset, msgBudget, cols])
+
+ const scrollUp = (n: number) => {
+ setScrollOffset(prev => Math.min(Math.max(0, messages.length - 1), prev + n))
+ stickyRef.current = false
+ }
+ const scrollDown = (n: number) => {
+ setScrollOffset(prev => { const next = Math.max(0, prev - n); if (next === 0) stickyRef.current = true; return next })
+ }
+ const scrollBottom = () => { setScrollOffset(0); stickyRef.current = true }
+
+ const sys = useCallback((text: string) => setMessages(p => [...p, { role: 'system' as const, text }]), [])
+ const idle = () => { setThinking(false); setTools([]); setBusy(false); setClarify(null); setApproval(null); setReasoning('') }
+ const die = () => { gw.kill(); exit() }
+ const clearIn = () => { setInput(''); setInputBuf([]); setQueueEdit(null); setHistoryIdx(null); historyDraftRef.current = '' }
+ const blocked = !!(clarify || approval)
+
+ // ── Hotkeys ───────────────────────────────────────────────────────
+
+ useInput((ch, key) => {
+ if (blocked) {
+ if (key.ctrl && ch === 'c' && approval) {
+ gw.request('approval.respond', { session_id: sid, choice: 'deny' }).catch(() => {})
+ setApproval(null); sys('denied')
+ }
+ return
+ }
+
+ if (key.pageUp) { scrollUp(5); return }
+ if (key.pageDown) { scrollDown(5); return }
+
+ if (key.upArrow && !inputBuf.length) {
+ if (queueRef.current.length) {
+ const len = queueRef.current.length
+ const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % len
+ setQueueEdit(idx)
+ setHistoryIdx(null)
+ setInput(queueRef.current[idx] ?? '')
+ return
+ }
+
+ const h = historyRef.current
+ if (!h.length) return
+ const idx = historyIdx === null ? h.length - 1 : Math.max(0, historyIdx - 1)
+ if (historyIdx === null) historyDraftRef.current = input
+ setHistoryIdx(idx)
+ setQueueEdit(null)
+ setInput(h[idx] ?? '')
+ return
+ }
+
+ if (key.downArrow && !inputBuf.length) {
+ if (queueRef.current.length) {
+ const len = queueRef.current.length
+ const idx = queueEditIdx === null ? len - 1 : (queueEditIdx - 1 + len) % len
+ setQueueEdit(idx)
+ setHistoryIdx(null)
+ setInput(queueRef.current[idx] ?? '')
+ return
+ }
+
+ if (historyIdx === null) return
+ const h = historyRef.current
+ const next = historyIdx + 1
+ if (next >= h.length) {
+ setHistoryIdx(null)
+ setInput(historyDraftRef.current)
+ } else {
+ setHistoryIdx(next)
+ setInput(h[next] ?? '')
+ }
+ return
+ }
+
+ if (key.ctrl && ch === 'c') {
+ if (busy && sid) {
+ gw.request('session.interrupt', { session_id: sid }).catch(() => {})
+ idle(); setStatus('interrupted'); sys('interrupted by user')
+ setTimeout(() => setStatus('ready'), 1500)
+ } else if (input || inputBuf.length) {
+ clearIn()
+ } else {
+ die()
+ }
+ return
+ }
+
+ if (key.ctrl && ch === 'd') die()
+ if (key.ctrl && ch === 'l') setMessages([])
+ if (key.escape) clearIn()
+ })
+
+ // ── Gateway events ────────────────────────────────────────────────
+
+ const onEvent = useCallback((ev: GatewayEvent) => {
+ const p = ev.payload as any
+
+ switch (ev.type) {
+ case 'gateway.ready':
+ if (p?.skin) setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {}))
+ setStatus('forging session…')
+ gw.request('session.create')
+ .then((r: any) => { setSid(r.session_id); setStatus('ready') })
+ .catch((e: Error) => setStatus(`error: ${e.message}`))
+ break
+
+ case 'session.info': setInfo(p as SessionInfo); break
+ case 'thinking.delta': break
+ case 'message.start': setThinking(true); setBusy(true); setReasoning(''); setStatus('thinking…'); break
+ case 'status.update': if (p?.text) setStatus(p.text); break
+
+ case 'reasoning.delta':
+ if (p?.text) setReasoning(prev => prev + p.text)
+ break
+
+ case 'tool.generating':
+ if (p?.name) setStatus(`preparing ${p.name}…`)
+ break
+
+ case 'tool.progress':
+ if (p?.preview) setMessages(prev => {
+ if (prev.at(-1)?.role === 'tool') return [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }]
+ return [...prev, { role: 'tool' as const, text: `${p.name}: ${p.preview}` }]
+ })
+ break
+
+ case 'tool.start':
+ setTools(prev => [...prev, { id: p.tool_id, name: p.name }])
+ setStatus(`running ${p.name}…`)
+ setMessages(prev => [...prev, { role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}…` }])
+ break
+
+ case 'tool.complete':
+ setTools(prev => prev.filter(t => t.id !== p.tool_id))
+ break
+
+ case 'clarify.request':
+ setClarify({ requestId: p.request_id, question: p.question, choices: p.choices })
+ setStatus('waiting for input…')
+ break
+
+ case 'approval.request':
+ setApproval({ command: p.command, description: p.description })
+ setStatus('approval needed')
+ break
+
+ case 'message.delta':
+ if (!p?.text) break
+ buf.current += p.text
+ setThinking(false); setTools([]); setReasoning('')
+ setMessages(prev => upsert(prev, 'assistant', buf.current.trimStart()))
+ break
+
+ case 'message.complete':
+ idle()
+ setMessages(prev => upsert(prev, 'assistant', (p?.text ?? buf.current).trimStart()))
+ buf.current = ''; setStatus('ready')
+ if (p?.usage) setUsage(p.usage)
+ if (p?.status === 'interrupted') sys('response interrupted')
+ if (queueEditIdxRef.current !== null) break
+
+ // drain queued message
+ const next = dequeue()
+ if (next) {
+ setLastUserMsg(next)
+ setMessages(prev => [...prev, { role: 'user' as const, text: next }])
+ setStatus('thinking…'); setBusy(true); buf.current = ''
+ gw.request('prompt.submit', { session_id: ev.session_id, text: next })
+ .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) })
+ }
+ break
+
+ case 'error':
+ sys(`error: ${p?.message}`)
+ idle(); setStatus('ready')
+ break
+ }
+ }, [gw, sys])
+
+ useEffect(() => {
+ gw.on('event', onEvent)
+ gw.on('exit', () => { setStatus('gateway exited'); exit() })
+ return () => { gw.off('event', onEvent) }
+ }, [gw, exit, onEvent])
+
+ // ── Slash commands ────────────────────────────────────────────────
+
+ const slash = useCallback((cmd: string): boolean => {
+ const [name, ...rest] = cmd.slice(1).split(/\s+/)
+ const arg = rest.join(' ')
+
+ switch (name) {
+ case 'help':
+ sys([
+ ' Commands:',
+ ...COMMANDS.map(([c, d]) => ` ${c.padEnd(12)} ${d}`),
+ '', ' Hotkeys:',
+ ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(12)} ${d}`),
+ ].join('\n'))
+ return true
+
+ case 'clear': setMessages([]); return true
+ case 'quit': case 'exit': die(); return true
+
+ case 'new':
+ setStatus('forging session…')
+ gw.request('session.create')
+ .then((r: any) => {
+ setSid(r.session_id); setMessages([]); setUsage(ZERO)
+ setStatus('ready'); sys('new session started')
+ })
+ .catch((e: Error) => setStatus(`error: ${e.message}`))
+ return true
+
+ case 'undo':
+ if (!sid) return true
+ gw.request('session.undo', { session_id: sid })
+ .then((r: any) => {
+ if (r.removed > 0) {
+ setMessages(p => { const q = [...p]; while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop(); if (q.at(-1)?.role === 'user') q.pop(); return q })
+ sys(`undid ${r.removed} messages`)
+ } else sys('nothing to undo')
+ })
+ .catch((e: Error) => sys(`error: ${e.message}`))
+ return true
+
+ case 'retry':
+ if (!lastUserMsg) { sys('nothing to retry'); return true }
+ setMessages(p => { const q = [...p]; while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop(); return q })
+ setMessages(p => [...p, { role: 'user', text: lastUserMsg }])
+ setStatus('thinking…'); setBusy(true); buf.current = ''
+ gw.request('prompt.submit', { session_id: sid, text: lastUserMsg })
+ .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) })
+ return true
+
+ case 'compact':
+ if (arg) { setCompact(true); sys(`compact on, focus: ${arg}`) }
+ else { setCompact(c => !c); sys(`compact ${compact ? 'off' : 'on'}`) }
+ return true
+
+ case 'compress':
+ if (!sid) return true
+ gw.request('session.compress', { session_id: sid })
+ .then((r: any) => { sys('context compressed'); if (r.usage) setUsage(r.usage) })
+ .catch((e: Error) => sys(`error: ${e.message}`))
+ return true
+
+ case 'cost': case 'usage':
+ sys(`in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}`)
+ return true
+
+ case 'copy': {
+ const msgs = messages.filter(m => m.role === 'assistant')
+ const target = msgs[arg ? Math.min(parseInt(arg), msgs.length) - 1 : msgs.length - 1]
+ if (!target) { sys('nothing to copy'); return true }
+ process.stdout.write(`\x1b]52;c;${Buffer.from(target.text).toString('base64')}\x07`)
+ sys('copied to clipboard')
+ return true
+ }
+
+ case 'context': {
+ const pct = Math.min(100, Math.round((usage.total / MAX_CTX) * 100))
+ const filled = Math.round((pct / 100) * 30)
+ sys(`context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(filled)}${'░'.repeat(30 - filled)}] ${pct < 50 ? '✓' : pct < 80 ? '⚠' : '✗'}`)
+ return true
+ }
+
+ case 'config':
+ sys(`model: ${info?.model ?? '?'} session: ${sid ?? 'none'} compact: ${compact}\ntools: ${flat(info?.tools ?? {}).length} skills: ${flat(info?.skills ?? {}).length}`)
+ return true
+
+ case 'status':
+ sys(`session: ${sid ?? 'none'} status: ${status} tokens: ${fmtK(usage.input)}↑ ${fmtK(usage.output)}↓ (${usage.calls} calls)`)
+ return true
+
+ case 'skills':
+ if (!info?.skills || !Object.keys(info.skills).length) { sys('no skills loaded'); return true }
+ sys(Object.entries(info.skills).map(([k, vs]) => `${k}: ${vs.join(', ')}`).join('\n'))
+ return true
+
+ case 'model':
+ if (!arg) { sys('usage: /model '); return true }
+ gw.request('config.set', { key: 'model', value: arg })
+ .then(() => sys(`model → ${arg}`))
+ .catch((e: Error) => sys(`error: ${e.message}`))
+ return true
+
+ case 'skin':
+ if (!arg) { sys('usage: /skin '); return true }
+ gw.request('config.set', { key: 'skin', value: arg })
+ .then(() => sys(`skin → ${arg} (restart to apply)`))
+ .catch((e: Error) => sys(`error: ${e.message}`))
+ return true
+
+ default: return false
+ }
+ }, [gw, sid, status, sys, compact, info, usage, messages, lastUserMsg])
+
+ // ── Submit ────────────────────────────────────────────────────────
+
+ const submit = useCallback((value: string) => {
+ if (!value.trim() && !inputBuf.length) {
+ const now = Date.now()
+ const dbl = now - lastEmptySubmitAt.current < 450
+ lastEmptySubmitAt.current = now
+
+ if (dbl && queueRef.current.length) {
+ if (busy && sid) {
+ gw.request('session.interrupt', { session_id: sid }).catch(() => {})
+ setStatus('interrupting…')
+ return
+ }
+
+ const next = dequeue()
+ if (next && sid) {
+ setQueueEdit(null)
+ setLastUserMsg(next)
+ setMessages(p => [...p, { role: 'user', text: next }])
+ setStatus('thinking…'); setBusy(true); buf.current = ''
+ gw.request('prompt.submit', { session_id: sid, text: next })
+ .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) })
+ }
+ }
+ return
+ }
+ lastEmptySubmitAt.current = 0
+
+ if (value.endsWith('\\')) {
+ setInputBuf(prev => [...prev, value.slice(0, -1)])
+ setInput('')
+ return
+ }
+
+ const full = [...inputBuf, value].join('\n')
+ setInputBuf([])
+ if (!full.trim() || !sid) return
+ setInput('')
+ setHistoryIdx(null)
+ historyDraftRef.current = ''
+
+ const editIdx = queueEditIdxRef.current
+
+ // editing an already queued entry
+ if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) {
+ replaceQueued(editIdx, full)
+ setQueueEdit(null)
+ return
+ } else if (editIdx !== null) {
+ setQueueEdit(null)
+ }
+ pushHistory(full)
+
+ // queue if busy (slash commands still run immediately)
+ if (busy && !full.startsWith('/') && !full.startsWith('!')) {
+ enqueue(full)
+ return
+ }
+
+ // !command → direct shell exec (entire line after !)
+ if (full.startsWith('!')) {
+ setMessages(p => [...p, { role: 'user', text: full }])
+ setBusy(true); setStatus('running…')
+ gw.request('shell.exec', { command: full.slice(1).trim() })
+ .then((r: any) => {
+ const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim()
+ sys(out || `exit ${r.code}`)
+ if (r.code !== 0 && out) sys(`exit ${r.code}`)
+ })
+ .catch((e: Error) => sys(`error: ${e.message}`))
+ .finally(() => { setStatus('ready'); setBusy(false) })
+ return
+ }
+
+ if (full.startsWith('/') && slash(full)) return
+
+ // {!cmd} inline shell interpolation
+ if (/\{!.+?\}/.test(full)) {
+ setBusy(true); setStatus('interpolating…')
+ const re = /\{!(.+?)\}/g
+ const matches = [...full.matchAll(re)]
+ Promise.all(matches.map(m =>
+ gw.request('shell.exec', { command: m[1]! })
+ .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim().split('\n')[0] ?? '')
+ .catch(() => '(error)')
+ )).then(results => {
+ let text = full
+ for (let i = matches.length - 1; i >= 0; i--)
+ text = text.slice(0, matches[i]!.index!) + results[i] + text.slice(matches[i]!.index! + matches[i]![0].length)
+ setLastUserMsg(text)
+ setMessages(p => [...p, { role: 'user', text }])
+ setStatus('thinking…'); buf.current = ''
+ gw.request('prompt.submit', { session_id: sid, text })
+ .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) })
+ })
+ return
+ }
+
+ setLastUserMsg(full)
+ setMessages(p => [...p, { role: 'user', text: full }])
+ scrollBottom()
+ setStatus('thinking…'); setBusy(true); buf.current = ''
+ gw.request('prompt.submit', { session_id: sid, text: full })
+ .catch((e: Error) => { sys(`error: ${e.message}`); setStatus('ready'); setBusy(false) })
+ }, [gw, sid, slash, sys, inputBuf, busy])
+
+ // ── Render ────────────────────────────────────────────────────────
+
+ const statusColor = status === 'ready' ? theme.color.ok
+ : status.startsWith('error') ? theme.color.error
+ : status === 'interrupted' ? theme.color.warn
+ : theme.color.dim
+ const queueWindow = 3
+ const queueStart = queueEditIdx === null
+ ? 0
+ : Math.max(0, Math.min(queueEditIdx - 1, Math.max(0, queuedDisplay.length - queueWindow)))
+ const queueEnd = Math.min(queuedDisplay.length, queueStart + queueWindow)
+ const queueShown = queuedDisplay.slice(queueStart, queueEnd)
+
+ return (
+
+
+
+ {empty ? (
+ <>
+
+ {info && }
+
+ {!sid
+ ? ⚕ {status}
+ :
+ type / for commands
+ {' · '}! for shell
+ {' · '}Ctrl+C to interrupt
+ }
+ >
+ ) : (
+
+ {theme.brand.icon}
+ {theme.brand.name}
+
+
+ {info?.model ? ` · ${info.model.split('/').pop()}` : ''}
+ {' · '}{status}
+ {busy && ' · Ctrl+C to stop'}
+
+
+ {usage.total > 0 && (
+
+ {' · '}{fmtK(usage.input)}↑ {fmtK(usage.output)}↓ ({usage.calls} calls)
+
+ )}
+
+ )}
+
+
+ {visibleSlice.above > 0 && (
+ ↑ {visibleSlice.above} above · PgUp/PgDn to scroll
+ )}
+ {messages.slice(visibleSlice.start, visibleSlice.end).map((m, i) => {
+ const ri = visibleSlice.start + i
+ return (
+ 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0}>
+
+
+ )
+ })}
+ {scrollOffset > 0 && (
+ ↓ {scrollOffset} below · PgDn or Enter to return
+ )}
+ {thinking && }
+
+
+ {clarify && (
+ {
+ gw.request('clarify.respond', { request_id: clarify.requestId, answer }).catch(() => {})
+ setMessages(p => [...p, { role: 'user', text: answer }])
+ setClarify(null); setStatus('thinking…')
+ }} />
+ )}
+
+ {approval && (
+ {
+ gw.request('approval.respond', { session_id: sid, choice }).catch(() => {})
+ setApproval(null)
+ sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
+ setStatus('running…')
+ }} />
+ )}
+
+ {!blocked && input.startsWith('/') && }
+
+ {queuedDisplay.length > 0 && (
+
+
+ queued ({queuedDisplay.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
+
+ {queueStart > 0 && (
+ …
+ )}
+ {queueShown.map((q, i) => {
+ const idx = queueStart + i
+ const active = queueEditIdx === idx
+ return (
+
+ {active ? '▸' : ' '} {idx + 1}. {compactPreview(q, Math.max(16, cols - 10))}
+
+ )
+ })}
+ {queueEnd < queuedDisplay.length && (
+
+ {' '}…and {queuedDisplay.length - queueEnd} more
+
+ )}
+
+ )}
+
+ {'─'.repeat(cols - 2)}
+
+ {!blocked && (
+
+
+
+ {inputBuf.length ? '… ' : `${theme.brand.prompt} `}
+
+
+
+
+ )}
+
+
+
+ )
+}
+
+
+function upsert(prev: Msg[], role: Role, text: string): Msg[] {
+ return prev.at(-1)?.role === role
+ ? [...prev.slice(0, -1), { role, text }]
+ : [...prev, { role, text }]
+}
+
+
+if (!process.stdin.isTTY) {
+ console.log('hermes-tui: no TTY (run in a real terminal)')
+ process.exit(0)
+}
+
+const gw = new GatewayClient()
+gw.start()
+render(, { exitOnCtrlC: false })
diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts
new file mode 100644
index 000000000..0a212d4df
--- /dev/null
+++ b/ui-tui/src/theme.ts
@@ -0,0 +1,105 @@
+export interface ThemeColors {
+ gold: string
+ amber: string
+ bronze: string
+ cornsilk: string
+ dim: string
+
+ label: string
+ ok: string
+ error: string
+ warn: string
+
+ statusBg: string
+ statusFg: string
+ statusGood: string
+ statusWarn: string
+ statusBad: string
+ statusCritical: string
+}
+
+export interface ThemeBrand {
+ name: string
+ icon: string
+ prompt: string
+ welcome: string
+ goodbye: string
+ tool: string
+}
+
+export interface Theme {
+ color: ThemeColors
+ brand: ThemeBrand
+}
+
+
+export const DEFAULT_THEME: Theme = {
+ color: {
+ gold: '#FFD700',
+ amber: '#FFBF00',
+ bronze: '#CD7F32',
+ cornsilk: '#FFF8DC',
+ dim: '#B8860B',
+
+ label: '#4dd0e1',
+ ok: '#4caf50',
+ error: '#ef5350',
+ warn: '#ffa726',
+
+ statusBg: '#1a1a2e',
+ statusFg: '#C0C0C0',
+ statusGood: '#8FBC8F',
+ statusWarn: '#FFD700',
+ statusBad: '#FF8C00',
+ statusCritical: '#FF6B6B',
+ },
+
+ brand: {
+ name: 'Hermes Agent',
+ icon: '⚕',
+ prompt: '❯',
+ welcome: 'Type your message or /help for commands.',
+ goodbye: 'Goodbye! ⚕',
+ tool: '┊',
+ },
+}
+
+
+export function fromSkin(
+ colors: Record,
+ branding: Record,
+): Theme {
+ const d = DEFAULT_THEME
+ const c = (k: string) => colors[k]
+
+ return {
+ color: {
+ gold: c('banner_title') ?? d.color.gold,
+ amber: c('banner_accent') ?? d.color.amber,
+ bronze: c('banner_border') ?? d.color.bronze,
+ cornsilk: c('banner_text') ?? d.color.cornsilk,
+ dim: c('banner_dim') ?? d.color.dim,
+
+ label: c('ui_label') ?? d.color.label,
+ ok: c('ui_ok') ?? d.color.ok,
+ error: c('ui_error') ?? d.color.error,
+ warn: c('ui_warn') ?? d.color.warn,
+
+ statusBg: d.color.statusBg,
+ statusFg: d.color.statusFg,
+ statusGood: c('ui_ok') ?? d.color.statusGood,
+ statusWarn: c('ui_warn') ?? d.color.statusWarn,
+ statusBad: d.color.statusBad,
+ statusCritical: d.color.statusCritical,
+ },
+
+ brand: {
+ name: branding.agent_name ?? d.brand.name,
+ icon: d.brand.icon,
+ prompt: branding.prompt_symbol ?? d.brand.prompt,
+ welcome: branding.welcome ?? d.brand.welcome,
+ goodbye: branding.goodbye ?? d.brand.goodbye,
+ tool: d.brand.tool,
+ },
+ }
+}
diff --git a/ui-tui/tsconfig.json b/ui-tui/tsconfig.json
new file mode 100644
index 000000000..fe135bfec
--- /dev/null
+++ b/ui-tui/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "lib": ["ES2022"],
+ "types": ["node"],
+ "strict": true,
+ "esModuleInterop": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}