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"] +}